From 5e8ea21868c772d68a37dc3a6d0481391305d862 Mon Sep 17 00:00:00 2001
From: Karthikeyan Singaravelan <tir.karthi@gmail.com>
Date: Sun, 27 Apr 2025 13:53:24 +0530
Subject: [PATCH 1/3] Allow non-string valid JSON values in Variable import.

---
 .../core_api/datamodels/variables.py          |  4 +--
 .../openapi/v1-rest-api-generated.yaml        |  4 +--
 .../ui/openapi-gen/requests/schemas.gen.ts    |  5 ++--
 .../ui/openapi-gen/requests/types.gen.ts      |  4 ++-
 .../pages/Variables/ImportVariablesForm.tsx   | 18 +++----------
 .../core_api/routes/public/test_variables.py  | 26 ++++---------------
 .../airflowctl/api/datamodels/generated.py    |  6 ++++-
 7 files changed, 23 insertions(+), 44 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 2317d8a168b82..2905e752650cd 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
@@ -19,7 +19,7 @@
 
 import json
 
-from pydantic import Field, model_validator
+from pydantic import Field, JsonValue, model_validator
 
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
 from airflow.models.base import ID_LEN
@@ -54,7 +54,7 @@ class VariableBody(StrictBaseModel):
     """Variable serializer for bodies."""
 
     key: str = Field(max_length=ID_LEN)
-    value: str = Field(serialization_alias="val")
+    value: JsonValue = Field(serialization_alias="val")
     description: str | None = Field(default=None)
 
 
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
index 0cf4c38f93a3b..f6f60768ed542 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v1-rest-api-generated.yaml
@@ -9129,6 +9129,7 @@ components:
       - unixname
       title: JobResponse
       description: Job serializer for responses.
+    JsonValue: {}
     PatchTaskInstanceBody:
       properties:
         new_state:
@@ -10407,8 +10408,7 @@ components:
           maxLength: 250
           title: Key
         value:
-          type: string
-          title: Value
+          $ref: '#/components/schemas/JsonValue'
         description:
           anyOf:
           - type: string
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 68f9e8afa0f97..519b1ad3726de 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -3410,6 +3410,8 @@ export const $JobResponse = {
   description: "Job serializer for responses.",
 } as const;
 
+export const $JsonValue = {} as const;
+
 export const $PatchTaskInstanceBody = {
   properties: {
     new_state: {
@@ -5451,8 +5453,7 @@ export const $VariableBody = {
       title: "Key",
     },
     value: {
-      type: "string",
-      title: "Value",
+      $ref: "#/components/schemas/JsonValue",
     },
     description: {
       anyOf: [
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 84555019afd80..b32dff2c83b36 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -898,6 +898,8 @@ export type JobResponse = {
   unixname: string | null;
 };
 
+export type JsonValue = unknown;
+
 /**
  * Request body for Clear Task Instances endpoint.
  */
@@ -1323,7 +1325,7 @@ export type ValidationError = {
  */
 export type VariableBody = {
   key: string;
-  value: string;
+  value: JsonValue;
   description?: string | null;
 };
 
diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/ImportVariablesForm.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/ImportVariablesForm.tsx
index 2d414b6cc843b..37111f88a6d06 100644
--- a/airflow-core/src/airflow/ui/src/pages/Variables/ImportVariablesForm.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Variables/ImportVariablesForm.tsx
@@ -58,7 +58,7 @@ const ImportVariablesForm = ({ onClose }: ImportVariablesFormProps) => {
 
   const [actionIfExists, setActionIfExists] = useState<"fail" | "overwrite" | "skip">("fail");
   const [isParsing, setIsParsing] = useState(false);
-  const [fileContent, setFileContent] = useState<Record<string, string> | undefined>(undefined);
+  const [fileContent, setFileContent] = useState<Record<string, unknown> | undefined>(undefined);
 
   const onFileChange = (file: File) => {
     setIsParsing(true);
@@ -67,21 +67,9 @@ const ImportVariablesForm = ({ onClose }: ImportVariablesFormProps) => {
     reader.addEventListener("load", (event) => {
       try {
         const text = event.target?.result as string;
-        const parsedContent = JSON.parse(text) as unknown;
+        const parsedContent = JSON.parse(text) as Record<string, unknown>;
 
-        if (
-          typeof parsedContent === "object" &&
-          parsedContent !== null &&
-          Object.entries(parsedContent).every(
-            ([key, value]) => typeof key === "string" && typeof value === "string",
-          )
-        ) {
-          const typedContent = parsedContent as Record<string, string>;
-
-          setFileContent(typedContent);
-        } else {
-          throw new Error("Invalid JSON format");
-        }
+        setFileContent(parsedContent);
       } catch {
         setError({
           body: {
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 1509a86b8624d..695c414efbb18 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
@@ -478,25 +478,6 @@ def test_post_should_respond_422_when_key_too_large(self, test_client):
             ]
         }
 
-    def test_post_should_respond_422_when_value_is_null(self, test_client):
-        body = {
-            "key": "null value key",
-            "value": None,
-            "description": "key too large",
-        }
-        response = test_client.post("/variables", json=body)
-        assert response.status_code == 422
-        assert response.json() == {
-            "detail": [
-                {
-                    "type": "string_type",
-                    "loc": ["body", "value"],
-                    "msg": "Input should be a valid string",
-                    "input": None,
-                }
-            ]
-        }
-
     @pytest.mark.parametrize(
         "body",
         [
@@ -728,7 +709,10 @@ class TestBulkVariables(TestVariableEndpoint):
                     "actions": [
                         {
                             "action": "create",
-                            "entities": [{"key": "new_var1", "value": "new_value1"}],
+                            "entities": [
+                                {"key": "new_var1", "value": "new_value1"},
+                                {"key": "new_var2", "value": ["new_value1"]},
+                            ],
                             "action_on_existence": "skip",
                         },
                         {
@@ -750,7 +734,7 @@ class TestBulkVariables(TestVariableEndpoint):
                     ]
                 },
                 {
-                    "create": {"success": ["new_var1"], "errors": []},
+                    "create": {"success": ["new_var1", "new_var2"], "errors": []},
                     "update": {"success": ["test_variable_key"], "errors": []},
                     "delete": {"success": ["dictionary_password"], "errors": []},
                 },
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 9eb789e11519b..0d2b7b3a8d5e4 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -562,6 +562,10 @@ class JobResponse(BaseModel):
     unixname: Annotated[str | None, Field(title="Unixname")] = None
 
 
+class JsonValue(RootModel[Any]):
+    root: Any
+
+
 class PluginImportErrorResponse(BaseModel):
     """
     Plugin Import Error serializer for responses.
@@ -837,7 +841,7 @@ class VariableBody(BaseModel):
         extra="forbid",
     )
     key: Annotated[str, Field(max_length=250, title="Key")]
-    value: Annotated[str, Field(title="Value")]
+    value: JsonValue
     description: Annotated[str | None, Field(title="Description")] = None
 
 

From d42287767263c526a2ca9cea309c47f48b74e02b Mon Sep 17 00:00:00 2001
From: Karthikeyan Singaravelan <tir.karthi@gmail.com>
Date: Mon, 28 Apr 2025 18:21:55 +0530
Subject: [PATCH 2/3] Add more tests.

---
 .../api_fastapi/core_api/routes/public/test_variables.py  | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

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 695c414efbb18..e6c359a3eb8e0 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
@@ -712,6 +712,9 @@ class TestBulkVariables(TestVariableEndpoint):
                             "entities": [
                                 {"key": "new_var1", "value": "new_value1"},
                                 {"key": "new_var2", "value": ["new_value1"]},
+                                {"key": "new_var3", "value": 1},
+                                {"key": "new_var4", "value": None},
+                                {"key": "new_var5", "value": {"foo": "bar"}},
                             ],
                             "action_on_existence": "skip",
                         },
@@ -734,7 +737,10 @@ class TestBulkVariables(TestVariableEndpoint):
                     ]
                 },
                 {
-                    "create": {"success": ["new_var1", "new_var2"], "errors": []},
+                    "create": {
+                        "success": ["new_var1", "new_var2", "new_var3", "new_var4", "new_var5"],
+                        "errors": [],
+                    },
                     "update": {"success": ["test_variable_key"], "errors": []},
                     "delete": {"success": ["dictionary_password"], "errors": []},
                 },

From 621127df2d2d8018fafce896bae91eb2ea4f33cc Mon Sep 17 00:00:00 2001
From: Karthikeyan Singaravelan <tir.karthi@gmail.com>
Date: Wed, 30 Apr 2025 12:22:51 +0530
Subject: [PATCH 3/3] Fix tests.

---
 airflow-ctl/tests/airflow_ctl/api/test_operations.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 9b69e318fa279..83a983cd310b3 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -662,10 +662,12 @@ class TestVariablesOperations:
     key = "key"
     value = "val"
     description = "description"
-    variable = VariableBody(
-        key=key,
-        value=value,
-        description=description,
+    variable = VariableBody.model_validate(
+        {
+            "key": key,
+            "value": value,
+            "description": description,
+        }
     )
     variable_response = VariableResponse(
         key=key,
