Andrushika commented on code in PR #71003:
URL: https://github.com/apache/airflow/pull/71003#discussion_r3961124664


##########
airflow-core/src/airflow/api_fastapi/core_api/services/public/common.py:
##########
@@ -55,6 +56,10 @@ def handle_request(self) -> BulkResponse:
             if action.action == BulkAction.CREATE:
                 self.handle_bulk_create(action, results[action.action.value])
             elif action.action == BulkAction.UPDATE:
+                if action.entities:
+                    action.update_mask = validate_update_mask(
+                        cast("type[BaseModel]", type(action.entities[0])), 
action.update_mask
+                    )

Review Comment:
   Currently a bad mask fails the whole bulk request with 400, but we expect 
each action to report its own errors in `results.errors`, like the immutable 
field check in `apply_patch_with_update_mask` ends up doing.
   
   Would it make sense to move this into the per-action try block, so bulk 
clients see one shape?



##########
airflow-core/src/airflow/api_fastapi/common/parameters.py:
##########
@@ -2056,3 +2056,56 @@ def _optional_boolean(value: bool | None) -> bool | None:
         )
     ),
 ]
+
+
+# Update mask
+def validate_update_mask(patch_body_type: type[BaseModel], update_mask: 
list[str] | None) -> list[str] | None:
+    """
+    Reject ``update_mask`` entries that name no field of ``patch_body_type``.
+
+    Every caller narrows the patch down with ``set(update_mask)``, which drops 
an entry matching
+    nothing -- so a typo used to make the whole request a no-op that still 
answered ``200``.
+    Aliases count as known names because that is what a caller sends in the 
body and reads back in
+    the response; which of the two a given endpoint acts on is left untouched 
here. Surrounding
+    whitespace is stripped so a stray space selects the field instead of 
silently selecting nothing.
+
+    Routes take the mask from the query string through 
:func:`update_mask_param_factory`; this is
+    for the bulk endpoints, which carry it in the request body instead.
+
+    :param patch_body_type: the request body type the mask selects fields from.
+    :param update_mask: the requested field names, or ``None``.
+    :return: the mask with whitespace stripped, or ``None``.
+    :raises HTTPException: 400 if any entry names no field.
+    """
+    if not update_mask:
+        return update_mask
+
+    fields = patch_body_type.model_fields
+    known = set(fields) | {
+        alias
+        for field in fields.values()
+        for alias in (field.alias, field.serialization_alias, 
field.validation_alias)

Review Comment:
   I think we should exclude `serialization_alias` here, it is too wide. It is 
the output name, not a name the body can send.
   
   For example: `PATCH /variables/k?update_mask=val` passes this check 
(`VariableBody.value` has `serialization_alias="val"`), but model_fields_set 
only has value, so it is a 200 no-op again.
   
   validation_alias alone should be enough, since alias fills it too.



##########
airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py:
##########
@@ -633,6 +633,33 @@ def test_patch_with_update_mask_description_only(self, 
test_client, session):
         assert response.json()["description"] == "updated description"
         assert response.json()["key"] == TEST_VARIABLE_KEY
 
+    @pytest.mark.parametrize(
+        "unknown",
+        ["valu", "definitely_not_a_field", ""],  # codespell:ignore valu
+    )
+    def test_patch_unknown_update_mask_field_returns_400(self, test_client, 
session, unknown):
+        self.create_variables()
+        response = test_client.patch(
+            f"/variables/{TEST_VARIABLE_KEY}",
+            json={"key": TEST_VARIABLE_KEY, "value": "new_value", 
"description": "new description"},
+            params={"update_mask": [unknown]},
+        )
+        assert response.status_code == 400
+        assert f"Unknown field(s) in update_mask: {unknown!r}" in 
response.json()["detail"]
+        # The request must not have been applied
+        stored = session.scalar(select(Variable).where(Variable.key == 
TEST_VARIABLE_KEY))
+        assert stored.val == TEST_VARIABLE_VALUE
+
+    def test_patch_update_mask_field_tolerates_surrounding_whitespace(self, 
test_client, session):
+        self.create_variables()
+        response = test_client.patch(
+            f"/variables/{TEST_VARIABLE_KEY}",
+            json={"key": TEST_VARIABLE_KEY, "value": "new_value", 
"description": "unchanged"},
+            params={"update_mask": [" value "]},
+        )
+        assert response.status_code == 200
+        assert response.json()["value"] == "new_value"
+

Review Comment:
   Only `variables` and `dag_run` have a 400 case now, and each route wires the 
dependency on its own line, so dropping one is not caught. Let's add test 
coverage for each endpoint to lock the wiring.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to