From c83e18bff870e0c6051395d6b932bfc551d0148b Mon Sep 17 00:00:00 2001 From: Devansh Date: Sat, 8 Aug 2026 20:13:08 +0530 Subject: [PATCH] Stop airflowctl update commands from clearing fields left unset The API treats every key present in a PATCH body as an intentional value, so an update that omitted a flag arrived with those fields set to null and silently cleared them. Changing only a connection's host wiped its login, port, schema and description; pool and variable descriptions were lost the same way. argparse fills an omitted flag with None and the generated command builds the whole datamodel from those values, so by serialization time the client can no longer tell "not supplied" from "set to null". The create methods already guard against this; the update methods did not. The same omission was reported for dags trigger in #70327, where it surfaces as a request older API servers reject rather than as lost data. --- airflow-ctl/src/airflowctl/api/operations.py | 10 +++- .../tests/airflow_ctl/api/test_operations.py | 56 ++++++++++++++++--- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/airflow-ctl/src/airflowctl/api/operations.py b/airflow-ctl/src/airflowctl/api/operations.py index 0e56102788729..cf940c52c9cc1 100644 --- a/airflow-ctl/src/airflowctl/api/operations.py +++ b/airflow-ctl/src/airflowctl/api/operations.py @@ -430,7 +430,7 @@ def update( """Update a connection.""" self.response = self.client.patch( f"connections/{connection.connection_id}", - json=connection.model_dump(mode="json", by_alias=True), + json=connection.model_dump(mode="json", by_alias=True, exclude_none=True), ) return ConnectionResponse.model_validate_json(self.response.content) @@ -652,7 +652,9 @@ def delete(self, pool: str) -> str | ServerResponseError: def update(self, pool_body: PoolPatchBody) -> PoolResponse | ServerResponseError: """Update a pool.""" - self.response = self.client.patch(f"pools/{pool_body.pool}", json=pool_body.model_dump(mode="json")) + self.response = self.client.patch( + f"pools/{pool_body.pool}", json=pool_body.model_dump(mode="json", exclude_none=True) + ) return PoolResponse.model_validate_json(self.response.content) @@ -766,7 +768,9 @@ def delete(self, variable_key: str) -> str | ServerResponseError: def update(self, variable: VariableBody) -> VariableResponse | ServerResponseError: """Update a variable.""" - self.response = self.client.patch(f"variables/{variable.key}", json=variable.model_dump(mode="json")) + self.response = self.client.patch( + f"variables/{variable.key}", json=variable.model_dump(mode="json", exclude_none=True) + ) return VariableResponse.model_validate_json(self.response.content) diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py b/airflow-ctl/tests/airflow_ctl/api/test_operations.py index 1bc183d4f4592..4a889cb2b4bf6 100644 --- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py +++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py @@ -877,14 +877,7 @@ def handle_request(request: httpx.Request) -> httpx.Response: assert request_body == { "connection_id": self.connection_id, "conn_type": self.conn_type, - "description": None, - "host": None, - "login": None, "schema": self.schema_, - "port": None, - "password": None, - "extra": None, - "team_name": None, } assert "schema_" not in request_body return httpx.Response( @@ -895,6 +888,30 @@ def handle_request(request: httpx.Request) -> httpx.Response: response = client.connections.update(connection=connection) assert response == self.connection_response + def test_update_omits_unset_fields_from_request_body(self): + # The API treats every key present in a PATCH body as an intentional value, so sending + # unset fields as null clears the stored login, port, schema and description. + connection = ConnectionBody( + connection_id=self.connection_id, + conn_type=self.conn_type, + host="new-host", + ) + + def handle_request(request: httpx.Request) -> httpx.Response: + assert request.url.path == f"/api/v2/connections/{self.connection_id}" + assert json.loads(request.content.decode()) == { + "connection_id": self.connection_id, + "conn_type": self.conn_type, + "host": "new-host", + } + return httpx.Response( + 200, json=json.loads(self.connection_response.model_dump_json(by_alias=True)) + ) + + client = make_api_client(transport=httpx.MockTransport(handle_request)) + response = client.connections.update(connection=connection) + assert response == self.connection_response + def test_test(self): connection_test_response = ConnectionTestResponse( status=True, @@ -1709,6 +1726,19 @@ def handle_request(request: httpx.Request) -> httpx.Response: response = client.pools.update(pool_body=self.pool_patch_body) assert response == self.pool_response + def test_update_omits_unset_fields_from_request_body(self): + def handle_request(request: httpx.Request) -> httpx.Response: + assert request.url.path == f"/api/v2/pools/{self.pool_name}" + assert json.loads(request.content.decode()) == { + "pool": self.pool_name, + "description": "description", + } + return httpx.Response(200, json=json.loads(self.pool_response.model_dump_json())) + + client = make_api_client(transport=httpx.MockTransport(handle_request)) + response = client.pools.update(pool_body=self.pool_patch_body) + assert response == self.pool_response + class TestProvidersOperations: provider_response = ProviderResponse( @@ -1978,6 +2008,18 @@ def handle_request(request: httpx.Request) -> httpx.Response: response = client.variables.update(variable=self.variable) assert response == self.variable_response + def test_update_omits_unset_fields_from_request_body(self): + variable = VariableBody.model_validate({"key": self.key, "value": "new-value"}) + + def handle_request(request: httpx.Request) -> httpx.Response: + assert request.url.path == f"/api/v2/variables/{self.key}" + assert json.loads(request.content.decode()) == {"key": self.key, "value": "new-value"} + return httpx.Response(200, json=json.loads(self.variable_response.model_dump_json())) + + client = make_api_client(transport=httpx.MockTransport(handle_request)) + response = client.variables.update(variable=variable) + assert response == self.variable_response + class TestVersionOperations: version_info = VersionInfo(