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 | undefined>(undefined); + const [fileContent, setFileContent] = useState | 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; - if ( - typeof parsedContent === "object" && - parsedContent !== null && - Object.entries(parsedContent).every( - ([key, value]) => typeof key === "string" && typeof value === "string", - ) - ) { - const typedContent = parsedContent as Record; - - 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..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 @@ -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,13 @@ 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"]}, + {"key": "new_var3", "value": 1}, + {"key": "new_var4", "value": None}, + {"key": "new_var5", "value": {"foo": "bar"}}, + ], "action_on_existence": "skip", }, { @@ -750,7 +737,10 @@ class TestBulkVariables(TestVariableEndpoint): ] }, { - "create": {"success": ["new_var1"], "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": []}, }, 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 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,