Skip to content

Commit 73cefcd

Browse files
committed
feat: add a request method to send raw HTTP requests
1 parent 5ce9732 commit 73cefcd

8 files changed

Lines changed: 149 additions & 0 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Added
1111
When the server advertises cursor pagination, a response without ``totalResults`` is accepted.
1212
A response with an invalid ``nextCursor`` or ``previousCursor`` raises
1313
:class:`~scim2_client.ResponsePayloadValidationException`.
14+
- The ``request`` method sends a raw request and returns the response of the HTTP library,
15+
without any SCIM processing. This helps to check how a server behaves,
16+
for instance with an unsupported HTTP method.
1417

1518
Changed
1619
^^^^^^^

‎doc/conf.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
("py:class", "scim2_client.client.ResourceT"),
6565
("py:class", "scim2_client.engines.httpx2.ResourceT"),
6666
("py:class", "scim2_client.engines.werkzeug.ResourceT"),
67+
# The httpx2 inventory does not publish its Response class.
68+
("py:class", "httpx2.Response"),
6769
]
6870

6971
# -- Sibling projects ------------------------------------------------------

‎doc/reference.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ here as class attributes.
4141
.. autoclass:: scim2_client.client.ResponseHeaders
4242
:members:
4343

44+
.. autoclass:: scim2_client.client.RawResponse
45+
:members:
46+
4447
Errors
4548
------
4649

‎scim2_client/client.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ class ResponseHeaders(Protocol):
7777
def get(self, key: str, /) -> str | None: ...
7878

7979

80+
class RawResponse(Protocol):
81+
"""A response as returned by the HTTP library of an engine."""
82+
83+
@property
84+
def status_code(self) -> int:
85+
"""The HTTP status code."""
86+
87+
@property
88+
def headers(self) -> ResponseHeaders:
89+
"""The HTTP headers."""
90+
91+
@property
92+
def text(self) -> str:
93+
"""The body, decoded as text."""
94+
95+
8096
def describe_resource_models(
8197
resource_models: Collection[type[Resource[Any]]],
8298
) -> tuple[tuple[DescribedModel, ...], tuple[ResourceType, ...]]:
@@ -1776,6 +1792,29 @@ def discover(
17761792
discovered_schemas, discovered_resource_types, config
17771793
)
17781794

1795+
def request(self, method: str, url: str, **kwargs: Any) -> RawResponse:
1796+
"""Send a request to the server, without any SCIM processing.
1797+
1798+
Neither the request nor the response is checked, so this is fitted to
1799+
observe how the server behaves, for instance with an unsupported HTTP method.
1800+
1801+
:param method: The HTTP method.
1802+
:param url: The URL, relative to the SCIM server base URL.
1803+
:param kwargs: Additional parameters passed to the underlying
1804+
HTTP request library.
1805+
:return: The response of the underlying HTTP request library.
1806+
:raises ~scim2_client.RequestNetworkException: When the request cannot be sent.
1807+
1808+
:usage:
1809+
1810+
.. code-block:: python
1811+
:caption: Checking that the ``/Schemas`` endpoint refuses ``DELETE``
1812+
1813+
response = scim.request("DELETE", "/Schemas")
1814+
assert response.status_code == 405
1815+
"""
1816+
raise NotImplementedError()
1817+
17791818

17801819
class BaseAsyncSCIMClient(SCIMClient):
17811820
"""Base class for asynchronous request clients."""
@@ -2275,3 +2314,26 @@ async def discover(
22752314
self.provider = self._describe_service(
22762315
discovered_schemas, discovered_resource_types, config
22772316
)
2317+
2318+
async def request(self, method: str, url: str, **kwargs: Any) -> RawResponse:
2319+
"""Send a request to the server, without any SCIM processing.
2320+
2321+
Neither the request nor the response is checked, so this is fitted to
2322+
observe how the server behaves, for instance with an unsupported HTTP method.
2323+
2324+
:param method: The HTTP method.
2325+
:param url: The URL, relative to the SCIM server base URL.
2326+
:param kwargs: Additional parameters passed to the underlying
2327+
HTTP request library.
2328+
:return: The response of the underlying HTTP request library.
2329+
:raises ~scim2_client.RequestNetworkException: When the request cannot be sent.
2330+
2331+
:usage:
2332+
2333+
.. code-block:: python
2334+
:caption: Checking that the ``/Schemas`` endpoint refuses ``DELETE``
2335+
2336+
response = await scim.request("DELETE", "/Schemas")
2337+
assert response.status_code == 405
2338+
"""
2339+
raise NotImplementedError()

‎scim2_client/engines/httpx2.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ def modify(
364364
),
365365
)
366366

367+
def request(self, method: str, url: str, **kwargs: Any) -> Response:
368+
with handle_request_error():
369+
return self.client.request(method, url, **kwargs)
370+
367371

368372
class AsyncSCIMClient(BaseAsyncSCIMClient):
369373
"""Perform SCIM requests over the network and validate responses.
@@ -655,3 +659,7 @@ async def modify(
655659
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
656660
),
657661
)
662+
663+
async def request(self, method: str, url: str, **kwargs: Any) -> Response:
664+
with handle_request_error():
665+
return await self.client.request(method, url, **kwargs)

‎scim2_client/engines/werkzeug.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,7 @@ def modify(
382382
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
383383
),
384384
)
385+
386+
def request(self, method: str, url: str, **kwargs: Any) -> TestResponse:
387+
environ = {**self.environ, **kwargs}
388+
return self.client.open(self._make_url(url), method=method, **environ)

‎tests/engines/test_httpx2.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from scim2_models import SearchRequest
1212
from scim2_models import ServiceProviderConfig
1313

14+
from scim2_client import RequestNetworkException
1415
from scim2_client.engines.httpx2 import AsyncClient
1516
from scim2_client.engines.httpx2 import AsyncSCIMClient
1617
from scim2_client.engines.httpx2 import Client
@@ -186,3 +187,41 @@ async def test_async_engine(server):
186187
assert operation.bulk_id == "qwerty"
187188
created_user = await scim_client.query(User, operation.location.rsplit("/", 1)[-1])
188189
assert created_user.user_name == "Bob"
190+
191+
192+
def test_sync_engine_request(server):
193+
"""A raw request reaches the server and returns its response as is."""
194+
host, port = server
195+
client = Client(base_url=f"http://{host}:{port}")
196+
scim_client = SyncSCIMClient(client)
197+
198+
response = scim_client.request("DELETE", "/Schemas")
199+
200+
assert response.status_code == 405
201+
202+
203+
async def test_async_engine_request(server):
204+
"""A raw asynchronous request reaches the server and returns its response as is."""
205+
host, port = server
206+
client = AsyncClient(base_url=f"http://{host}:{port}")
207+
scim_client = AsyncSCIMClient(client)
208+
209+
response = await scim_client.request("DELETE", "/Schemas")
210+
211+
assert response.status_code == 405
212+
213+
214+
def test_sync_engine_request_network_error():
215+
"""A raw request that cannot be sent raises RequestNetworkException."""
216+
scim_client = SyncSCIMClient(Client(base_url="http://invalid.test"))
217+
218+
with pytest.raises(RequestNetworkException):
219+
scim_client.request("GET", "/Schemas")
220+
221+
222+
async def test_async_engine_request_network_error():
223+
"""A raw asynchronous request that cannot be sent raises RequestNetworkException."""
224+
scim_client = AsyncSCIMClient(AsyncClient(base_url="http://invalid.test"))
225+
226+
with pytest.raises(RequestNetworkException):
227+
await scim_client.request("GET", "/Schemas")

‎tests/engines/test_werkzeug.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,31 @@ def application(request):
154154
provider=ScimProvider(models=[User]),
155155
)
156156
scim_client.query(url="/Users")
157+
158+
159+
def test_request(scim_client):
160+
"""A raw request reaches the application and returns its response as is."""
161+
response = scim_client.request("DELETE", "/Schemas")
162+
163+
assert response.status_code == 405
164+
165+
166+
def test_request_with_prefix_and_environ():
167+
"""A raw request is sent under the SCIM prefix, with the client environ."""
168+
169+
@Request.application
170+
def application(request):
171+
return Response(
172+
f"{request.method} {request.path} {request.headers['X-Test']}", status=405
173+
)
174+
175+
scim_client = TestSCIMClient(
176+
client=Client(application),
177+
environ={"headers": {"X-Test": "foobar"}},
178+
scim_prefix="/scim/v2",
179+
)
180+
181+
response = scim_client.request("DELETE", "/Schemas")
182+
183+
assert response.status_code == 405
184+
assert response.text == "DELETE /scim/v2/Schemas foobar"

0 commit comments

Comments
 (0)