From cd75707d7d3bf0ef2d8dceb0b08b05bcbc759709 Mon Sep 17 00:00:00 2001 From: Bugra Ozturk Date: Wed, 6 Nov 2024 09:43:56 +0100 Subject: [PATCH] AIP-84 Migrate patch a connection to FastAPI API (#43102) * Migrate Patch Connection endpoint to FastAPI and Include Optional parameter Field default value for backward compatibility * Make all tests parametrized * Make UpdateMask Annotated for Patch endpoints, update the serialisation for backward compat and fix for variables serialisation won't update val in setattr() * Merge duplicate imports * Revert update_mask to explode true and include significant.rst for update_mask breaking change * Amend update_mask as array * generalize handling of list query parameters in newsfragment * Include password field * Include password to response and tests, rebase and rerun pre-commit * Fix copy&paste mistakes for method and naming * Convert redact_password to agreed approach, rebase and pre-commits * Include new exception doc generation * Run pre-commit after rebase --- .../endpoints/connection_endpoint.py | 1 + .../core_api/openapi/v1-generated.yaml | 85 +++++- .../core_api/routes/public/connections.py | 39 ++- .../core_api/routes/public/dags.py | 8 +- .../core_api/routes/public/variables.py | 8 +- .../core_api/serializers/variables.py | 17 +- airflow/ui/openapi-gen/queries/common.ts | 3 + airflow/ui/openapi-gen/queries/queries.ts | 47 +++ .../ui/openapi-gen/requests/schemas.gen.ts | 20 +- .../ui/openapi-gen/requests/services.gen.ts | 36 +++ airflow/ui/openapi-gen/requests/types.gen.ts | 41 ++- newsfragments/43102.significant.rst | 18 ++ .../routes/public/test_connections.py | 275 ++++++++++++++++++ 13 files changed, 558 insertions(+), 40 deletions(-) create mode 100644 newsfragments/43102.significant.rst diff --git a/airflow/api_connexion/endpoints/connection_endpoint.py b/airflow/api_connexion/endpoints/connection_endpoint.py index c0c2fcbf4610a..a07e13b35255c 100644 --- a/airflow/api_connexion/endpoints/connection_endpoint.py +++ b/airflow/api_connexion/endpoints/connection_endpoint.py @@ -113,6 +113,7 @@ def get_connections( ) +@mark_fastapi_migration_done @security.requires_access_connection("PUT") @provide_session @action_logging( diff --git a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml index 46a2be61d64a0..1426a6b4cd409 100644 --- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml +++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml @@ -1044,6 +1044,72 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + patch: + tags: + - Connection + summary: Patch Connection + description: Update a connection entry. + operationId: patch_connection + parameters: + - name: connection_id + in: path + required: true + schema: + type: string + title: Connection Id + - name: update_mask + in: query + required: false + schema: + anyOf: + - type: array + items: + type: string + - type: 'null' + title: Update Mask + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionResponse' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Bad Request + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /public/connections/: get: tags: @@ -4349,20 +4415,19 @@ components: key: type: string title: Key - description: + value: anyOf: - type: string - type: 'null' - title: Description - value: + title: Value + description: anyOf: - type: string - type: 'null' - title: Value + title: Description type: object required: - key - - description - value title: VariableBody description: Variable serializer for bodies. @@ -4387,21 +4452,21 @@ components: key: type: string title: Key - description: + value: anyOf: - type: string - type: 'null' - title: Description - value: + title: Value + description: anyOf: - type: string - type: 'null' - title: Value + title: Description type: object required: - key - - description - value + - description title: VariableResponse description: Variable serializer for responses. VersionInfo: diff --git a/airflow/api_fastapi/core_api/routes/public/connections.py b/airflow/api_fastapi/core_api/routes/public/connections.py index f1c5eb30824c2..b1b6fb4abeb6e 100644 --- a/airflow/api_fastapi/core_api/routes/public/connections.py +++ b/airflow/api_fastapi/core_api/routes/public/connections.py @@ -16,7 +16,7 @@ # under the License. from __future__ import annotations -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session from typing_extensions import Annotated @@ -143,3 +143,40 @@ async def post_connection( session.add(connection) return ConnectionResponse.model_validate(connection, from_attributes=True) + + +@connections_router.patch( + "/{connection_id}", + responses=create_openapi_http_exception_doc( + [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + status.HTTP_404_NOT_FOUND, + ] + ), +) +async def patch_connection( + connection_id: str, + patch_body: ConnectionBody, + session: Annotated[Session, Depends(get_session)], + update_mask: list[str] | None = Query(None), +) -> ConnectionResponse: + """Update a connection entry.""" + if patch_body.connection_id != connection_id: + raise HTTPException(400, "The connection_id in the request body does not match the URL parameter") + + non_update_fields = {"connection_id", "conn_id"} + connection = session.scalar(select(Connection).filter_by(conn_id=connection_id).limit(1)) + + if connection is None: + raise HTTPException(404, f"The Connection with connection_id: `{connection_id}` was not found") + + if update_mask: + data = patch_body.model_dump(include=set(update_mask) - non_update_fields) + else: + data = patch_body.model_dump(exclude=non_update_fields) + + for key, val in data.items(): + setattr(connection, key, val) + return ConnectionResponse.model_validate(connection, from_attributes=True) diff --git a/airflow/api_fastapi/core_api/routes/public/dags.py b/airflow/api_fastapi/core_api/routes/public/dags.py index 2df243e47541c..a36c391e55dc8 100644 --- a/airflow/api_fastapi/core_api/routes/public/dags.py +++ b/airflow/api_fastapi/core_api/routes/public/dags.py @@ -220,12 +220,12 @@ async def patch_dag( status.HTTP_400_BAD_REQUEST, "Only `is_paused` field can be updated through the REST API" ) + data = patch_body.model_dump(include=set(update_mask)) else: - update_mask = ["is_paused"] + data = patch_body.model_dump() - for attr_name in update_mask: - attr_value = getattr(patch_body, attr_name) - setattr(dag, attr_name, attr_value) + for key, val in data.items(): + setattr(dag, key, val) return DAGResponse.model_validate(dag, from_attributes=True) diff --git a/airflow/api_fastapi/core_api/routes/public/variables.py b/airflow/api_fastapi/core_api/routes/public/variables.py index 5d2bf5a899d8a..6ed680cd7bc2f 100644 --- a/airflow/api_fastapi/core_api/routes/public/variables.py +++ b/airflow/api_fastapi/core_api/routes/public/variables.py @@ -139,12 +139,14 @@ async def patch_variable( status.HTTP_404_NOT_FOUND, f"The Variable with key: `{variable_key}` was not found" ) if update_mask: - data = patch_body.model_dump(include=set(update_mask) - non_update_fields) + data = patch_body.model_dump( + include=set(update_mask) - non_update_fields, by_alias=True, exclude_none=True + ) else: - data = patch_body.model_dump(exclude=non_update_fields) + data = patch_body.model_dump(exclude=non_update_fields, by_alias=True, exclude_none=True) for key, val in data.items(): setattr(variable, key, val) - return variable + return VariableResponse.model_validate(variable, from_attributes=True) @variables_router.post( diff --git a/airflow/api_fastapi/core_api/serializers/variables.py b/airflow/api_fastapi/core_api/serializers/variables.py index b328972544fd0..95f437c634b0b 100644 --- a/airflow/api_fastapi/core_api/serializers/variables.py +++ b/airflow/api_fastapi/core_api/serializers/variables.py @@ -25,19 +25,14 @@ from airflow.utils.log.secrets_masker import redact -class VariableBase(BaseModel): - """Base Variable serializer.""" +class VariableResponse(BaseModel): + """Variable serializer for responses.""" model_config = ConfigDict(populate_by_name=True) key: str - description: str | None - - -class VariableResponse(VariableBase): - """Variable serializer for responses.""" - val: str | None = Field(alias="value") + description: str | None @model_validator(mode="after") def redact_val(self) -> Self: @@ -54,10 +49,12 @@ def redact_val(self) -> Self: return self -class VariableBody(VariableBase): +class VariableBody(BaseModel): """Variable serializer for bodies.""" - value: str | None + key: str + value: str | None = Field(serialization_alias="val") + description: str | None = Field(default=None) class VariableCollectionResponse(BaseModel): diff --git a/airflow/ui/openapi-gen/queries/common.ts b/airflow/ui/openapi-gen/queries/common.ts index cec1f0f314dc7..f1cb514682ee8 100644 --- a/airflow/ui/openapi-gen/queries/common.ts +++ b/airflow/ui/openapi-gen/queries/common.ts @@ -723,6 +723,9 @@ export type DagServicePatchDagsMutationResult = Awaited< export type DagServicePatchDagMutationResult = Awaited< ReturnType >; +export type ConnectionServicePatchConnectionMutationResult = Awaited< + ReturnType +>; export type DagRunServicePatchDagRunStateMutationResult = Awaited< ReturnType >; diff --git a/airflow/ui/openapi-gen/queries/queries.ts b/airflow/ui/openapi-gen/queries/queries.ts index 11dea6f3df589..1ce766d3af866 100644 --- a/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow/ui/openapi-gen/queries/queries.ts @@ -1520,6 +1520,53 @@ export const useDagServicePatchDag = < }) as unknown as Promise, ...options, }); +/** + * Patch Connection + * Update a connection entry. + * @param data The data for the request. + * @param data.connectionId + * @param data.requestBody + * @param data.updateMask + * @returns ConnectionResponse Successful Response + * @throws ApiError + */ +export const useConnectionServicePatchConnection = < + TData = Common.ConnectionServicePatchConnectionMutationResult, + TError = unknown, + TContext = unknown, +>( + options?: Omit< + UseMutationOptions< + TData, + TError, + { + connectionId: string; + requestBody: ConnectionBody; + updateMask?: string[]; + }, + TContext + >, + "mutationFn" + >, +) => + useMutation< + TData, + TError, + { + connectionId: string; + requestBody: ConnectionBody; + updateMask?: string[]; + }, + TContext + >({ + mutationFn: ({ connectionId, requestBody, updateMask }) => + ConnectionService.patchConnection({ + connectionId, + requestBody, + updateMask, + }) as unknown as Promise, + ...options, + }); /** * Patch Dag Run State * Modify a DAG Run. diff --git a/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow/ui/openapi-gen/requests/schemas.gen.ts index d64abb3853b49..5635188d4fda4 100644 --- a/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -2820,7 +2820,7 @@ export const $VariableBody = { type: "string", title: "Key", }, - description: { + value: { anyOf: [ { type: "string", @@ -2829,9 +2829,9 @@ export const $VariableBody = { type: "null", }, ], - title: "Description", + title: "Value", }, - value: { + description: { anyOf: [ { type: "string", @@ -2840,11 +2840,11 @@ export const $VariableBody = { type: "null", }, ], - title: "Value", + title: "Description", }, }, type: "object", - required: ["key", "description", "value"], + required: ["key", "value"], title: "VariableBody", description: "Variable serializer for bodies.", } as const; @@ -2875,7 +2875,7 @@ export const $VariableResponse = { type: "string", title: "Key", }, - description: { + value: { anyOf: [ { type: "string", @@ -2884,9 +2884,9 @@ export const $VariableResponse = { type: "null", }, ], - title: "Description", + title: "Value", }, - value: { + description: { anyOf: [ { type: "string", @@ -2895,11 +2895,11 @@ export const $VariableResponse = { type: "null", }, ], - title: "Value", + title: "Description", }, }, type: "object", - required: ["key", "description", "value"], + required: ["key", "value", "description"], title: "VariableResponse", description: "Variable serializer for responses.", } as const; diff --git a/airflow/ui/openapi-gen/requests/services.gen.ts b/airflow/ui/openapi-gen/requests/services.gen.ts index 4bfc986b56ba1..6450029f56085 100644 --- a/airflow/ui/openapi-gen/requests/services.gen.ts +++ b/airflow/ui/openapi-gen/requests/services.gen.ts @@ -39,6 +39,8 @@ import type { DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, + PatchConnectionData, + PatchConnectionResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, @@ -636,6 +638,40 @@ export class ConnectionService { }); } + /** + * Patch Connection + * Update a connection entry. + * @param data The data for the request. + * @param data.connectionId + * @param data.requestBody + * @param data.updateMask + * @returns ConnectionResponse Successful Response + * @throws ApiError + */ + public static patchConnection( + data: PatchConnectionData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "PATCH", + url: "/public/connections/{connection_id}", + path: { + connection_id: data.connectionId, + }, + query: { + update_mask: data.updateMask, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 422: "Validation Error", + }, + }); + } + /** * Get Connections * Get all connection entries. diff --git a/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow/ui/openapi-gen/requests/types.gen.ts index 06799783653bd..f5d47e0e087fa 100644 --- a/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow/ui/openapi-gen/requests/types.gen.ts @@ -659,8 +659,8 @@ export type ValidationError = { */ export type VariableBody = { key: string; - description: string | null; value: string | null; + description?: string | null; }; /** @@ -676,8 +676,8 @@ export type VariableCollectionResponse = { */ export type VariableResponse = { key: string; - description: string | null; value: string | null; + description: string | null; }; /** @@ -873,6 +873,14 @@ export type GetConnectionData = { export type GetConnectionResponse = ConnectionResponse; +export type PatchConnectionData = { + connectionId: string; + requestBody: ConnectionBody; + updateMask?: Array | null; +}; + +export type PatchConnectionResponse = ConnectionResponse; + export type GetConnectionsData = { limit?: number; offset?: number; @@ -1537,6 +1545,35 @@ export type $OpenApiTs = { 422: HTTPValidationError; }; }; + patch: { + req: PatchConnectionData; + res: { + /** + * Successful Response + */ + 200: ConnectionResponse; + /** + * Bad Request + */ + 400: HTTPExceptionResponse; + /** + * Unauthorized + */ + 401: HTTPExceptionResponse; + /** + * Forbidden + */ + 403: HTTPExceptionResponse; + /** + * Not Found + */ + 404: HTTPExceptionResponse; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; }; "/public/connections/": { get: { diff --git a/newsfragments/43102.significant.rst b/newsfragments/43102.significant.rst new file mode 100644 index 0000000000000..e626ba5a42c34 --- /dev/null +++ b/newsfragments/43102.significant.rst @@ -0,0 +1,18 @@ +Change in query parameter handling for list parameters + +The handling of list-type query parameters in the API has been updated. +FastAPI defaults the ``explode`` behavior to ``true`` for list parameters, +which affects how these parameters are passed in requests. +This adjustment applies to all list-type query parameters across the API. + +Before: + +.. code-block:: + + http://:/?param=item1,item2 + +After: + +.. code-block:: + + http://:/?param=item1¶m=item2 diff --git a/tests/api_fastapi/core_api/routes/public/test_connections.py b/tests/api_fastapi/core_api/routes/public/test_connections.py index 1dc3cf9d2cd4d..67b58007f5318 100644 --- a/tests/api_fastapi/core_api/routes/public/test_connections.py +++ b/tests/api_fastapi/core_api/routes/public/test_connections.py @@ -30,6 +30,7 @@ TEST_CONN_DESCRIPTION = "some_description_a" TEST_CONN_HOST = "some_host_a" TEST_CONN_PORT = 8080 +TEST_CONN_LOGIN = "some_login" TEST_CONN_ID_2 = "test_connection_id_2" @@ -37,6 +38,7 @@ TEST_CONN_DESCRIPTION_2 = "some_description_b" TEST_CONN_HOST_2 = "some_host_b" TEST_CONN_PORT_2 = 8081 +TEST_CONN_LOGIN_2 = "some_login_b" @provide_session @@ -47,6 +49,7 @@ def _create_connection(session) -> None: description=TEST_CONN_DESCRIPTION, host=TEST_CONN_HOST, port=TEST_CONN_PORT, + login=TEST_CONN_LOGIN, ) session.add(connection_model) @@ -60,6 +63,7 @@ def _create_connections(session) -> None: description=TEST_CONN_DESCRIPTION_2, host=TEST_CONN_HOST_2, port=TEST_CONN_PORT_2, + login=TEST_CONN_LOGIN_2, ) session.add(connection_model_2) @@ -288,3 +292,274 @@ def test_post_should_response_201_redacted_password(self, test_client, body, exp response = test_client.post("/public/connections/", json=body) assert response.status_code == 201 assert response.json() == expected_response + + +class TestPatchConnection(TestConnectionEndpoint): + @pytest.mark.parametrize( + "payload", + [ + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "extra": '{"key": "var"}'}, + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "host": "test_host_patch"}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + "port": 80, + }, + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "login": "test_login_patch"}, + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "port": 80}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "port": 80, + "login": "test_login_patch", + }, + ], + ) + @provide_session + def test_patch_should_respond_200(self, test_client, payload, session): + self.create_connection() + + response = test_client.patch(f"/public/connections/{TEST_CONN_ID}", json=payload) + assert response.status_code == 200 + + @pytest.mark.parametrize( + "payload, updated_connection, update_mask", + [ + ( + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "extra": '{"key": "var"}'}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "extra": None, + "host": TEST_CONN_HOST, + "login": None, + "port": None, + "schema": None, + "password": None, + "description": TEST_CONN_DESCRIPTION, + }, + {"update_mask": ["login", "port"]}, + ), + ( + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "host": "test_host_patch"}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "extra": None, + "host": "test_host_patch", + "login": TEST_CONN_LOGIN, + "port": TEST_CONN_PORT, + "schema": None, + "password": None, + "description": TEST_CONN_DESCRIPTION, + }, + {"update_mask": ["host"]}, + ), + ( + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + "port": 80, + }, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "extra": None, + "host": "test_host_patch", + "login": TEST_CONN_LOGIN, + "port": 80, + "schema": None, + "password": None, + "description": TEST_CONN_DESCRIPTION, + }, + {"update_mask": ["host", "port"]}, + ), + ( + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "login": "test_login_patch"}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "extra": None, + "host": TEST_CONN_HOST, + "login": "test_login_patch", + "port": TEST_CONN_PORT, + "schema": None, + "password": None, + "description": TEST_CONN_DESCRIPTION, + }, + {"update_mask": ["login"]}, + ), + ( + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "host": TEST_CONN_HOST, + "port": 80, + }, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "extra": None, + "host": TEST_CONN_HOST, + "login": TEST_CONN_LOGIN, + "port": TEST_CONN_PORT, + "password": None, + "schema": None, + "description": TEST_CONN_DESCRIPTION, + }, + {"update_mask": ["host"]}, + ), + ], + ) + def test_patch_should_respond_200_with_update_mask( + self, test_client, session, payload, updated_connection, update_mask + ): + self.create_connection() + response = test_client.patch(f"/public/connections/{TEST_CONN_ID}", json=payload, params=update_mask) + assert response.status_code == 200 + connection = session.query(Connection).filter_by(conn_id=TEST_CONN_ID).first() + assert connection.password is None + assert response.json() == updated_connection + + @pytest.mark.parametrize( + "payload", + [ + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "extra": '{"key": "var"}', + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + "port": 80, + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "login": "test_login_patch", + }, + {"connection_id": "i_am_not_a_connection", "conn_type": TEST_CONN_TYPE, "port": 80}, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "port": 80, + "login": "test_login_patch", + }, + ], + ) + def test_patch_should_respond_400(self, test_client, payload): + self.create_connection() + response = test_client.patch(f"/public/connections/{TEST_CONN_ID}", json=payload) + assert response.status_code == 400 + print(response.json()) + assert { + "detail": "The connection_id in the request body does not match the URL parameter", + } == response.json() + + @pytest.mark.parametrize( + "payload", + [ + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "extra": '{"key": "var"}', + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "host": "test_host_patch", + "port": 80, + }, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "login": "test_login_patch", + }, + {"connection_id": "i_am_not_a_connection", "conn_type": TEST_CONN_TYPE, "port": 80}, + { + "connection_id": "i_am_not_a_connection", + "conn_type": TEST_CONN_TYPE, + "port": 80, + "login": "test_login_patch", + }, + ], + ) + def test_patch_should_respond_404(self, test_client, payload): + response = test_client.patch(f"/public/connections/{payload['connection_id']}", json=payload) + assert response.status_code == 404 + assert { + "detail": f"The Connection with connection_id: `{payload['connection_id']}` was not found", + } == response.json() + + @pytest.mark.enable_redact + @pytest.mark.parametrize( + "body, expected_response", + [ + ( + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "password": "test-password"}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "description": None, + "extra": None, + "host": None, + "login": None, + "password": "***", + "port": None, + "schema": None, + }, + ), + ( + {"connection_id": TEST_CONN_ID, "conn_type": TEST_CONN_TYPE, "password": "?>@#+!_%()#"}, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "description": None, + "extra": None, + "host": None, + "login": None, + "password": "***", + "port": None, + "schema": None, + }, + ), + ( + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "password": "A!rF|0wi$aw3s0m3", + "extra": '{"password": "test-password"}', + }, + { + "connection_id": TEST_CONN_ID, + "conn_type": TEST_CONN_TYPE, + "description": None, + "extra": '{"password": "***"}', + "host": None, + "login": None, + "password": "***", + "port": None, + "schema": None, + }, + ), + ], + ) + def test_patch_should_response_200_redacted_password(self, test_client, session, body, expected_response): + self.create_connections() + response = test_client.patch(f"/public/connections/{TEST_CONN_ID}", json=body) + assert response.status_code == 200 + assert response.json() == expected_response