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 9dc7969b69b6a..a781c79c6a220 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 @@ -32,7 +32,7 @@ class VariableResponse(BaseModel): """Variable serializer for responses.""" key: str - val: str = Field(alias="value") + val: str | None = Field(alias="value", default=None) description: str | None is_encrypted: bool team_name: str | None diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml index ede1d9a9545f0..92f5dcade8ac9 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml @@ -14977,7 +14977,9 @@ components: type: string title: Key value: - type: string + anyOf: + - type: string + - type: 'null' title: Value description: anyOf: @@ -14995,7 +14997,6 @@ components: type: object required: - key - - value - description - is_encrypted - team_name 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 0edc8f1c66815..1ae88b9433e20 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 @@ -7041,7 +7041,14 @@ export const $VariableResponse = { title: 'Key' }, value: { - type: 'string', + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], title: 'Value' }, description: { @@ -7072,7 +7079,7 @@ export const $VariableResponse = { } }, type: 'object', - required: ['key', 'value', 'description', 'is_encrypted', 'team_name'], + required: ['key', 'description', 'is_encrypted', 'team_name'], title: 'VariableResponse', description: 'Variable serializer for responses.' } as const; 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 9d519a7039cd9..3062baff3a8a3 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 @@ -1709,7 +1709,7 @@ export type VariableCollectionResponse = { */ export type VariableResponse = { key: string; - value: string; + value?: string | null; description: string | null; is_encrypted: boolean; team_name: string | null; diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx index 65d65014c359c..264da41bccc13 100644 --- a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx @@ -50,7 +50,7 @@ const EditVariableButton = ({ disabled, variable }: Props) => { description: variable.description ?? "", key: variable.key, team_name: variable.team_name ?? "", - value: formatValue(variable.value), + value: formatValue(variable.value ?? ""), }; const { editVariable, error, isPending, setError } = useEditVariable(initialVariableValue, { onSuccessConfirm: onClose, diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx index b44a020f5c530..bb7f8bda9c68e 100644 --- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx @@ -94,9 +94,9 @@ const getColumns = ({ cell: ({ row }) => ( ), 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 7e9f7b9623bad..a5baa43c283bd 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 @@ -254,6 +254,32 @@ def test_get_should_respond_404(self, test_client): body = response.json() assert f"The Variable with key: `{TEST_VARIABLE_KEY}` was not found" == body["detail"] + def test_get_should_respond_200_with_null_value_when_decryption_fails(self, test_client, session): + """ + Regression test for https://github.com/apache/airflow/pull/65452. + + If the stored value cannot be decrypted (for example after a Fernet key + rotation) ``Variable.get_val`` returns ``None``. The endpoint must then + respond with HTTP 200 and ``"value": null`` instead of failing with an + HTTP 500 caused by response-schema validation. + """ + from cryptography.fernet import InvalidToken + + self.create_variables() + with mock.patch("airflow.models.variable.get_fernet") as mock_get_fernet: + mock_get_fernet.return_value.decrypt.side_effect = InvalidToken + response = test_client.get(f"/variables/{TEST_VARIABLE_KEY}") + + assert response.status_code == 200 + body = response.json() + assert body == { + "key": TEST_VARIABLE_KEY, + "value": None, + "description": TEST_VARIABLE_DESCRIPTION, + "is_encrypted": True, + "team_name": None, + } + class TestGetVariables(TestVariableEndpoint): @pytest.mark.enable_redact diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py index 3e158bbf73993..9b3eff63fb143 100644 --- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py +++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py @@ -992,7 +992,7 @@ class VariableResponse(BaseModel): """ key: Annotated[str, Field(title="Key")] - value: Annotated[str, Field(title="Value")] + value: Annotated[str | None, Field(title="Value")] = None description: Annotated[str | None, Field(title="Description")] = None is_encrypted: Annotated[bool, Field(title="Is Encrypted")] team_name: Annotated[str | None, Field(title="Team Name")] = None