From 77aed5554e29909b58eda0c56b8015eca7c47a44 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Sun, 4 Oct 2026 00:04:34 +0800 Subject: [PATCH] feat(client): accept query= on put/delete/patch like get() get() translated query= into session params; the other verbs only took json=, forcing callers to hand-build URLs when a non-GET endpoint reads the query string (e.g. campus.auth's DELETE /auth/v1/connections/{provider}/{integration}/?user_id=...). query= is optional and defaults to None, so existing calls are unchanged. Closes #68. --- campus_python/json_client/__init__.py | 33 ++++++++++++--- campus_python/json_client/interface.py | 21 ++++++++-- tests/unit/test_json_client_query.py | 56 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_json_client_query.py diff --git a/campus_python/json_client/__init__.py b/campus_python/json_client/__init__.py index 609c7d0..c90c396 100644 --- a/campus_python/json_client/__init__.py +++ b/campus_python/json_client/__init__.py @@ -169,29 +169,50 @@ def post(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: raise errors.ServerError(error_description=str(exc)) from None return CampusResponse(resp) - def put(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: + def put( + self: Self, + path: str, + json: JsonDict | None = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a PUT request.""" url = self._build_url(path) try: - resp = self._session.put(url, json=json, timeout=self._timeout) + resp = self._session.put( + url, json=json, params=query, timeout=self._timeout + ) except requests.RequestException as exc: raise errors.ServerError(error_description=str(exc)) from None return CampusResponse(resp) - def delete(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: + def delete( + self: Self, + path: str, + json: JsonDict | None = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a DELETE request.""" url = self._build_url(path) try: - resp = self._session.delete(url, json=json, timeout=self._timeout) + resp = self._session.delete( + url, json=json, params=query, timeout=self._timeout + ) except requests.RequestException as exc: raise errors.ServerError(error_description=str(exc)) from None return CampusResponse(resp) - def patch(self: Self, path: str, json: Any = None) -> JsonResponse: + def patch( + self: Self, + path: str, + json: Any = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a PATCH request.""" url = self._build_url(path) try: - resp = self._session.patch(url, json=json, timeout=self._timeout) + resp = self._session.patch( + url, json=json, params=query, timeout=self._timeout + ) except requests.RequestException as exc: raise errors.ServerError(error_description=str(exc)) from None return CampusResponse(resp) diff --git a/campus_python/json_client/interface.py b/campus_python/json_client/interface.py index 09e2ed7..b08f09e 100644 --- a/campus_python/json_client/interface.py +++ b/campus_python/json_client/interface.py @@ -151,13 +151,28 @@ def post(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: """Sends a POST request.""" @abstractmethod - def put(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: + def put( + self: Self, + path: str, + json: JsonDict | None = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a PUT request.""" @abstractmethod - def delete(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse: + def delete( + self: Self, + path: str, + json: JsonDict | None = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a DELETE request.""" @abstractmethod - def patch(self: Self, path: str, json: Any = None) -> JsonResponse: + def patch( + self: Self, + path: str, + json: Any = None, + query: JsonDict | None = None + ) -> JsonResponse: """Sends a PATCH request.""" diff --git a/tests/unit/test_json_client_query.py b/tests/unit/test_json_client_query.py new file mode 100644 index 0000000..d4f727b --- /dev/null +++ b/tests/unit/test_json_client_query.py @@ -0,0 +1,56 @@ +"""Tests for the query= parameter on non-GET JsonClient verbs. + +get() has always taken query=; put/delete/patch gained it so callers +don't hand-build URLs when a non-GET endpoint reads the query string +(e.g. campus.auth's DELETE /auth/v1/connections/.../?user_id=...). +""" + +import os +import unittest +from unittest import mock + +os.environ.setdefault("CLIENT_ID", "test-client-id") +os.environ.setdefault("CLIENT_SECRET", "test-client-secret") + +from campus_python.json_client import CampusRequest + + +class TestQueryKwargOnNonGetVerbs(unittest.TestCase): + """query= rides the session params on every verb, not just get().""" + + def setUp(self): + self.client = CampusRequest( + base_url="https://auth.example.test", mode="device" + ) + + def _assert_params_passed(self, verb: str, session_method: mock.Mock): + self.client._timeout = 5 + with mock.patch.object( + self.client._session, session_method, + return_value=mock.Mock()) as method: + getattr(self.client, verb)( + "/some/path", json={"k": "v"}, query={"user_id": "u"}) + _, kwargs = method.call_args + self.assertEqual(kwargs["json"], {"k": "v"}) + self.assertEqual(kwargs["params"], {"user_id": "u"}) + + def test_put_passes_query_as_params(self): + self._assert_params_passed("put", "put") + + def test_delete_passes_query_as_params(self): + self._assert_params_passed("delete", "delete") + + def test_patch_passes_query_as_params(self): + self._assert_params_passed("patch", "patch") + + def test_query_defaults_to_none(self): + """Omitting query= sends params=None, preserving old behavior.""" + with mock.patch.object( + self.client._session, "delete", + return_value=mock.Mock()) as delete: + self.client.delete("/some/path") + self.assertIsNone(delete.call_args.kwargs["params"]) + + +if __name__ == "__main__": + unittest.main()