From 8336b5e0c613e1a09026444136b7b48a89d9796d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 6 Oct 2026 14:04:04 +0200 Subject: [PATCH 1/2] feat: add WSGI and ASGI engines with no dependency WSGISCIMClient and ASGISCIMClient call a WSGI or an ASGI application directly, without a network. They only need the standard library. TestSCIMClient and the werkzeug extra are deprecated. --- README.md | 2 +- doc/changelog.rst | 14 ++ doc/conf.py | 5 + doc/how-to/use-another-engine.rst | 71 ++++++-- doc/reference/engines.rst | 12 ++ scim2_client/engines/asgi.py | 175 +++++++++++++++++++ scim2_client/engines/inprocess.py | 153 +++++++++++++++++ scim2_client/engines/werkzeug.py | 11 ++ scim2_client/engines/wsgi.py | 159 +++++++++++++++++ tests/engines/test_asgi.py | 256 +++++++++++++++++++++++++++ tests/engines/test_inprocess.py | 135 +++++++++++++++ tests/engines/test_werkzeug.py | 8 + tests/engines/test_wsgi.py | 277 ++++++++++++++++++++++++++++++ tests/test_bulk.py | 23 +-- tests/test_endpoints.py | 23 ++- tests/test_etags.py | 36 ++-- tests/test_hostile_responses.py | 35 ++-- tests/test_typing.py | 21 ++- 18 files changed, 1333 insertions(+), 83 deletions(-) create mode 100644 scim2_client/engines/asgi.py create mode 100644 scim2_client/engines/inprocess.py create mode 100644 scim2_client/engines/wsgi.py create mode 100644 tests/engines/test_asgi.py create mode 100644 tests/engines/test_inprocess.py create mode 100644 tests/engines/test_wsgi.py diff --git a/README.md b/README.md index da27725..8876b1d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It allows users and groups creations, modifications and deletions to be synchron - **Server Discovery**: Automatic retrieval of `ServiceProviderConfig`, `ResourceTypes` and `Schemas` - **Search & Filtering**: Support for SCIM filters, sorting, pagination and attribute selection - **Sync & Async**: Both synchronous and asynchronous clients available -- **Multiple HTTP Engines**: Built-in support for [httpx2](https://github.com/pydantic/httpx2) (sync/async) and [werkzeug](https://werkzeug.palletsprojects.com/) (testing). Adaptable to any network engine. +- **Multiple HTTP Engines**: Built-in support for [httpx2](https://github.com/pydantic/httpx2) (sync/async) and in-process WSGI and ASGI applications (testing), with no dependency. Adaptable to any network engine. - **Request & Response Validation**: Automatic payload validation against SCIM schemas - **Error Handling**: Structured exceptions for network, request and response errors diff --git a/doc/changelog.rst b/doc/changelog.rst index 0559431..cb85c0e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,20 @@ Changelog ========= +[0.12.0] - Unreleased +--------------------- + +Added +^^^^^ +- :class:`~scim2_client.engines.wsgi.WSGISCIMClient` and + :class:`~scim2_client.engines.asgi.ASGISCIMClient` call a WSGI or an ASGI application directly, + without a network. They only need the standard library, and work with any framework. + +Deprecated +^^^^^^^^^^ +- :class:`~scim2_client.engines.werkzeug.TestSCIMClient` and the ``werkzeug`` extra. Use + :class:`~scim2_client.engines.wsgi.WSGISCIMClient` instead. Will be removed in 1.0. + [0.11.2] - 2026-10-02 --------------------- diff --git a/doc/conf.py b/doc/conf.py index 0da698f..60dd5ae 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -64,8 +64,13 @@ ("py:class", "scim2_client.client.ResourceT"), ("py:class", "scim2_client.engines.httpx2.ResourceT"), ("py:class", "scim2_client.engines.werkzeug.ResourceT"), + ("py:class", "scim2_client.engines.wsgi.ResourceT"), + ("py:class", "scim2_client.engines.asgi.ResourceT"), # The httpx2 inventory does not publish its Response class. ("py:class", "httpx2.Response"), + # The WSGI types only exist in typeshed. + ("py:class", "WSGIApplication"), + ("py:class", "WSGIEnvironment"), ] # -- Sibling projects ------------------------------------------------------ diff --git a/doc/how-to/use-another-engine.rst b/doc/how-to/use-another-engine.rst index d8ecc7f..c57b33b 100644 --- a/doc/how-to/use-another-engine.rst +++ b/doc/how-to/use-another-engine.rst @@ -16,36 +16,71 @@ Pick a shipped engine :class:`~scim2_client.engines.httpx2.AsyncSCIMClient` The same API, awaited. It is the engine an asynchronous application uses. -:class:`~scim2_client.engines.werkzeug.TestSCIMClient` - Takes a WSGI application and executes the server code directly, without a network. This is - faster in a test suite, and an exception raised by the server surfaces in the test rather - than turning into a ``500``. +:class:`~scim2_client.engines.wsgi.WSGISCIMClient` + Calls a WSGI application directly, without a network. This is faster in a test suite, and an + exception raised by the server surfaces in the test rather than turning into a ``500``. + +:class:`~scim2_client.engines.asgi.ASGISCIMClient` + The same, awaited, for an ASGI application. Test a SCIM server without a network ------------------------------------ -:class:`~scim2_client.engines.werkzeug.TestSCIMClient` is meant for the authors of SCIM servers. -It takes a :class:`Werkzeug test Client ` wrapping the application, and a -prefix when the SCIM endpoints are not served at the root: +:class:`~scim2_client.engines.wsgi.WSGISCIMClient` and +:class:`~scim2_client.engines.asgi.ASGISCIMClient` are meant for the authors of SCIM servers. They +only need the standard library, and work with any WSGI or ASGI application, such as a Flask, +Django or Starlette application, or the applications of +`scim2-server `_. Pass the application, and the URL of the +SCIM endpoints when they are not served at the root: -.. code-block:: python +.. tab-set:: + :class: outline - from scim2_client.engines.werkzeug import TestSCIMClient - from scim2_models import Group, ScimProvider, User - from werkzeug.test import Client + .. tab-item:: WSGI + :sync: sync - scim = TestSCIMClient( - Client(myapp.create_app()), - scim_prefix="/scim/v2", - provider=ScimProvider(models=[User, Group]), - ) + .. code-block:: python + + from scim2_client.engines.wsgi import WSGISCIMClient + from scim2_models import Group, ScimProvider, User - user = scim.create(User(user_name="bjensen@example.com")) - assert user.id + scim = WSGISCIMClient( + myapp.create_app(), + base_url="http://localhost/scim/v2", + provider=ScimProvider(models=[User, Group]), + ) + + user = scim.create(User(user_name="bjensen@example.com")) + assert user.id + + .. tab-item:: ASGI + :sync: async + + .. code-block:: python + + from scim2_client.engines.asgi import ASGISCIMClient + from scim2_models import Group, ScimProvider, User + + scim = ASGISCIMClient( + myapp.create_app(), + base_url="http://localhost/scim/v2", + provider=ScimProvider(models=[User, Group]), + ) + + user = await scim.create(User(user_name="bjensen@example.com")) + assert user.id The client checks the payloads the application produces, and a compliance mistake fails the test. +The ``headers`` parameter gives headers to send with every request, such as ``Authorization``. +The ``environ`` parameter of the WSGI engine and the ``scope`` parameter of the ASGI engine add +keys to every request, such as ``REMOTE_USER``. The ASGI engine sends no ``lifespan`` message: +start an application that needs them apart. + +:class:`~scim2_client.engines.werkzeug.TestSCIMClient`, which needs Werkzeug, is deprecated in +favor of :class:`~scim2_client.engines.wsgi.WSGISCIMClient`. + Write an engine --------------- diff --git a/doc/reference/engines.rst b/doc/reference/engines.rst index ad86807..c52b36f 100644 --- a/doc/reference/engines.rst +++ b/doc/reference/engines.rst @@ -12,6 +12,18 @@ and inherits the payload handling from the base client. :members: :member-order: bysource +.. autoclass:: scim2_client.engines.wsgi.WSGISCIMClient + :members: + :member-order: bysource + +.. autoclass:: scim2_client.engines.asgi.ASGISCIMClient + :members: + :member-order: bysource + +.. autoclass:: scim2_client.engines.inprocess.InProcessResponse + :members: + :member-order: bysource + .. autoclass:: scim2_client.engines.werkzeug.TestSCIMClient :members: :member-order: bysource diff --git a/scim2_client/engines/asgi.py b/scim2_client/engines/asgi.py new file mode 100644 index 0000000..07c46af --- /dev/null +++ b/scim2_client/engines/asgi.py @@ -0,0 +1,175 @@ +import asyncio +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import MutableMapping +from typing import Any +from typing import cast +from urllib.parse import unquote +from wsgiref.headers import Headers + +from scim2_client.client import BaseAsyncSCIMClient +from scim2_client.client import RawResponse +from scim2_client.engines.inprocess import InProcessResponse +from scim2_client.engines.inprocess import _check_base_url +from scim2_client.engines.inprocess import _default_port +from scim2_client.engines.inprocess import _HeadersT +from scim2_client.engines.inprocess import _InProcessRequest +from scim2_client.engines.inprocess import _prepare_request +from scim2_client.engines.inprocess import _stays_under_base_url + +_Scope = MutableMapping[str, Any] +_Message = MutableMapping[str, Any] +_Receive = Callable[[], Awaitable[_Message]] +_Send = Callable[[_Message], Awaitable[None]] +_ASGIApplication = Callable[[_Scope, _Receive, _Send], Awaitable[None]] + + +def _build_scope(request: _InProcessRequest, extra: _Scope) -> _Scope: + """Return the ASGI scope of an HTTP request.""" + url = request.url + scope: _Scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": request.method, + "scheme": url.scheme, + "path": unquote(url.path), + "raw_path": url.path.encode(), + "query_string": url.query.encode(), + "root_path": "", + "headers": [ + (name.lower().encode("latin-1"), value.encode("latin-1")) + for name, value in request.headers + ], + "client": ("127.0.0.1", 123), + "server": (url.hostname or "", _default_port(url)), + } + return {**scope, **extra} + + +async def _call_application( + app: _ASGIApplication, scope: _Scope, body: bytes +) -> InProcessResponse: + """Call an ASGI application, and return its response once its body is sent.""" + started: list[InProcessResponse] = [] + chunks: list[bytes] = [] + request_sent = False + response_complete = asyncio.Event() + + async def receive() -> _Message: + nonlocal request_sent + if not request_sent: + request_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + # A request is disconnected only once its response is sent, so an + # application listening for the disconnection, such as a streaming + # response, is not interrupted. + await response_complete.wait() + return {"type": "http.disconnect"} + + async def send(message: _Message) -> None: + if message["type"] == "http.response.start": + started[:] = [ + InProcessResponse( + status_code=message["status"], + headers=Headers( + [ + (name.decode("latin-1"), value.decode("latin-1")) + for name, value in message.get("headers", []) + ] + ), + ) + ] + return + + if not started: + raise RuntimeError( + "The ASGI application sent a body before starting the response" + ) + chunks.append(message.get("body", b"")) + if not message.get("more_body", False): + response_complete.set() + + await app(scope, receive, send) + + if not response_complete.is_set(): + raise RuntimeError("The ASGI application returned before sending its response") + + response = started[0] + response.content = b"".join(chunks) + return response + + +class ASGISCIMClient(BaseAsyncSCIMClient): + """Call an ASGI application directly, without a network. + + This is helpful for developers of asynchronous SCIM servers. The server code + runs in the test event loop, so an exception it raises reaches the test + instead of turning into a ``500`` response. It only needs the standard + library, and runs with :mod:`asyncio`. + + The ``lifespan`` messages are not sent: an application that needs them + must be started apart. + + :param app: The ASGI 3 application. + :param base_url: The absolute URL of the SCIM endpoints, such as ``http://localhost/scim/v2``. + Its path is passed in the ``path`` of the scope, and ``root_path`` is empty. + :param headers: Headers sent with every request. + The headers passed to a request replace those of the same name. + :param scope: Keys added to the ASGI scope of every request, + such as ``state`` or ``user``. + :param provider: The :class:`~scim2_models.ScimProvider` describing the server. + If a request payload describe a resource it does not know, an exception will be raised. + The :class:`~scim2_models.ScimPolicy` it carries rules how much the payloads + exchanged with the server may depart from the specification. + :param check_request_payload: If :data:`False`, + :code:`resource` is expected to be a dict that will be passed as-is in the request. + This value can be overwritten in methods. + :param check_response_payload: Whether to validate that the response payloads are valid. + If set, the raw payload will be returned. This value can be overwritten in methods. + :param raise_scim_errors: If :data:`True` and the server returned an + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` + exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. + + .. code-block:: python + + from scim2_client.engines.asgi import ASGISCIMClient + from scim2_models import Group, ScimProvider, User + + scim = ASGISCIMClient( + myapp.create_app(), + base_url="http://localhost/scim/v2", + provider=ScimProvider(models=[User, Group]), + ) + + user = await scim.create(User(user_name="bjensen@example.com")) + assert user.id + """ + + def __init__( + self, + app: _ASGIApplication, + base_url: str = "http://localhost", + headers: _HeadersT | None = None, + scope: _Scope | None = None, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.app = app + self.base_url = _check_base_url(base_url) + self.headers = headers + self.scope = scope or {} + + def _stays_under_base_url(self, endpoint: str) -> bool: + return _stays_under_base_url(self.base_url, endpoint) + + def _parse_json(self, response: RawResponse) -> Any: + return cast(InProcessResponse, response).json() + + async def request(self, method: str, url: str, **kwargs: Any) -> InProcessResponse: + request = _prepare_request(self.base_url, self.headers, method, url, **kwargs) + return await _call_application( + self.app, _build_scope(request, self.scope), request.body + ) diff --git a/scim2_client/engines/inprocess.py b/scim2_client/engines/inprocess.py new file mode 100644 index 0000000..8b9fb81 --- /dev/null +++ b/scim2_client/engines/inprocess.py @@ -0,0 +1,153 @@ +"""The parts of the WSGI and ASGI engines that do not depend on the protocol.""" + +import json as jsonlib +from collections.abc import Iterable +from collections.abc import Mapping +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from urllib.parse import SplitResult +from urllib.parse import urlencode +from urllib.parse import urljoin +from urllib.parse import urlsplit +from wsgiref.headers import Headers + +_HeadersT = Mapping[str, str] | Iterable[tuple[str, str]] + + +@dataclass +class InProcessResponse: + """The response of an application called by an in-process engine. + + It has the attributes of :class:`~scim2_client.client.RawResponse`. + """ + + status_code: int + """The HTTP status code.""" + + headers: Headers = field(default_factory=lambda: Headers([])) + """The HTTP headers. Their names are case insensitive.""" + + content: bytes = b"" + """The raw body.""" + + @property + def text(self) -> str: + """The body, decoded as UTF-8 (:rfc:`8259#section-8.1`).""" + return self.content.decode("utf-8", errors="replace") + + def json(self) -> Any: + """Return the body, decoded as JSON.""" + return jsonlib.loads(self.content) + + +@dataclass +class _InProcessRequest: + """A request ready to be passed to an application.""" + + method: str + url: SplitResult + headers: list[tuple[str, str]] + body: bytes + + +def _header_pairs(headers: _HeadersT | None) -> list[tuple[str, str]]: + if headers is None: + return [] + if isinstance(headers, Mapping): + return list(headers.items()) + return list(headers) + + +def _merge_headers( + defaults: _HeadersT | None, overrides: _HeadersT | None +) -> list[tuple[str, str]]: + """Return the default headers, replaced by the overriding headers of the same name.""" + pairs = _header_pairs(overrides) + names = {name.lower() for name, _ in pairs} + kept = [pair for pair in _header_pairs(defaults) if pair[0].lower() not in names] + return kept + pairs + + +def _check_base_url(base_url: str) -> str: + parts = urlsplit(base_url) + if not parts.scheme or not parts.netloc: + raise ValueError(f"The base URL '{base_url}' is not absolute") + return base_url + + +def _build_url(base_url: str, url: str) -> SplitResult: + """Return the URL a request is sent to, with its dot segments resolved. + + A relative URL is appended to the path of the base URL, as httpx does. + """ + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in url): + raise ValueError(f"The URL {url!r} has control characters") + + if not urlsplit(url).scheme: + base_path = urlsplit(base_url).path.rstrip("/") + url = f"{base_path}/{url.lstrip('/')}" + return urlsplit(urljoin(base_url, url)) + + +def _default_port(url: SplitResult) -> int: + return url.port or (443 if url.scheme == "https" else 80) + + +def _origin(url: SplitResult) -> tuple[str, str | None, int]: + return url.scheme, url.hostname, _default_port(url) + + +def _stays_under_base_url(base_url: str, endpoint: str) -> bool: + """Tell whether the URL built for an endpoint has the origin and the path prefix of the base URL.""" + base = urlsplit(base_url) + try: + url = _build_url(base_url, endpoint) + same_origin = _origin(url) == _origin(base) + except ValueError: + return False + + prefix = f"{base.path.rstrip('/')}/" + return same_origin and f"{url.path.rstrip('/')}/".startswith(prefix) + + +def _prepare_request( + base_url: str, + default_headers: _HeadersT | None, + method: str, + url: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | None = None, + headers: _HeadersT | None = None, +) -> _InProcessRequest: + """Build a request from the arguments of the request method of an engine. + + The parameters are those of httpx: ``params`` for the query string, + ``json`` or ``content`` for the body, and ``headers``. + """ + if json is not None and content is not None: + raise TypeError("Pass either 'json' or 'content', not both") + + target = _build_url(base_url, url) + if params: + query = urlencode(params, doseq=True) + target = target._replace( + query=f"{target.query}&{query}" if target.query else query + ) + + pairs = [("Host", target.netloc)] + body = content or b"" + if json is not None: + body = jsonlib.dumps(json).encode() + pairs.append(("Content-Type", "application/json")) + if body: + pairs.append(("Content-Length", str(len(body)))) + + return _InProcessRequest( + method=method.upper(), + url=target, + headers=_merge_headers(_merge_headers(pairs, default_headers), headers), + body=body, + ) diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py index 77f6d98..d802dbc 100644 --- a/scim2_client/engines/werkzeug.py +++ b/scim2_client/engines/werkzeug.py @@ -8,6 +8,7 @@ from scim2_client.client import BaseSyncSCIMClient from scim2_client.client import RawResponse +from scim2_client.client import _deprecation class TestSCIMClient(BaseSyncSCIMClient): @@ -17,6 +18,11 @@ class TestSCIMClient(BaseSyncSCIMClient): This client avoids to perform real HTTP requests and directly execute the server code instead. This allows to dynamically catch the exceptions if something gets wrong. + .. deprecated:: 0.12 + + Use :class:`~scim2_client.engines.wsgi.WSGISCIMClient`, which needs no dependency. + Will be removed in 1.0. + :param client: An optional custom :class:`Werkzeug test Client `. If :data:`None` a default client is initialized. :param scim_prefix: The scim root endpoint in the application. @@ -64,6 +70,11 @@ def __init__( *args: Any, **kwargs: Any, ) -> None: + _deprecation( + "'TestSCIMClient' is deprecated, use " + "'scim2_client.engines.wsgi.WSGISCIMClient' instead. " + "Will be removed in 1.0." + ) super().__init__(*args, **kwargs) self.client = client self.scim_prefix = scim_prefix diff --git a/scim2_client/engines/wsgi.py b/scim2_client/engines/wsgi.py new file mode 100644 index 0000000..35bc7ef --- /dev/null +++ b/scim2_client/engines/wsgi.py @@ -0,0 +1,159 @@ +import sys +from collections.abc import Callable +from collections.abc import Iterable +from io import BytesIO +from typing import TYPE_CHECKING +from typing import Any +from typing import cast +from urllib.parse import unquote_to_bytes +from wsgiref.headers import Headers + +from scim2_client.client import BaseSyncSCIMClient +from scim2_client.client import RawResponse +from scim2_client.engines.inprocess import InProcessResponse +from scim2_client.engines.inprocess import _check_base_url +from scim2_client.engines.inprocess import _default_port +from scim2_client.engines.inprocess import _HeadersT +from scim2_client.engines.inprocess import _InProcessRequest +from scim2_client.engines.inprocess import _prepare_request +from scim2_client.engines.inprocess import _stays_under_base_url + +if TYPE_CHECKING: + from _typeshed import OptExcInfo + from _typeshed.wsgi import WSGIApplication + from _typeshed.wsgi import WSGIEnvironment + + +def _build_environ( + request: _InProcessRequest, extra: "WSGIEnvironment" +) -> "WSGIEnvironment": + """Return the WSGI environment of a request (PEP 3333).""" + url = request.url + environ: WSGIEnvironment = { + "REQUEST_METHOD": request.method, + "SCRIPT_NAME": "", + # PEP 3333 passes the bytes of the path as a latin-1 string. + "PATH_INFO": unquote_to_bytes(url.path).decode("latin-1"), + "QUERY_STRING": url.query, + "SERVER_NAME": url.hostname or "", + "SERVER_PORT": str(_default_port(url)), + "SERVER_PROTOCOL": "HTTP/1.1", + "REMOTE_ADDR": "127.0.0.1", + "wsgi.version": (1, 0), + "wsgi.url_scheme": url.scheme, + "wsgi.input": BytesIO(request.body), + "wsgi.errors": sys.stderr, + "wsgi.multithread": False, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + } + for name, value in request.headers: + key = name.upper().replace("-", "_") + if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"): + key = f"HTTP_{key}" + environ[key] = f"{environ[key]}, {value}" if key in environ else value + return {**environ, **extra} + + +def _call_application( + app: "WSGIApplication", environ: "WSGIEnvironment" +) -> InProcessResponse: + """Call a WSGI application, and return its response once its body is read.""" + started: list[tuple[str, list[tuple[str, str]]]] = [] + chunks: list[bytes] = [] + + def start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: "OptExcInfo | None" = None, + ) -> Callable[[bytes], object]: + # The response is sent once the application returns, so headers set + # again after an error replace the previous ones (PEP 3333). + started[:] = [(status, headers)] + return chunks.append + + result: Iterable[bytes] = app(environ, start_response) + try: + chunks.extend(result) + finally: + close = getattr(result, "close", None) + if close is not None: + close() + + if not started: + raise RuntimeError("The WSGI application did not call 'start_response'") + + status, headers = started[0] + return InProcessResponse( + status_code=int(status.split(" ", 1)[0]), + headers=Headers(headers), + content=b"".join(chunks), + ) + + +class WSGISCIMClient(BaseSyncSCIMClient): + """Call a WSGI application directly, without a network. + + This is helpful for developers of SCIM servers. The server code runs in the + test process, so an exception it raises reaches the test instead of turning + into a ``500`` response. It only needs the standard library. + + :param app: The WSGI application (:pep:`3333`). + :param base_url: The absolute URL of the SCIM endpoints, such as ``http://localhost/scim/v2``. + Its path is passed in ``PATH_INFO``, and ``SCRIPT_NAME`` is empty. + :param headers: Headers sent with every request. + The headers passed to a request replace those of the same name. + :param environ: Keys added to the WSGI environment of every request, + such as ``REMOTE_USER``. + :param provider: The :class:`~scim2_models.ScimProvider` describing the server. + If a request payload describe a resource it does not know, an exception will be raised. + The :class:`~scim2_models.ScimPolicy` it carries rules how much the payloads + exchanged with the server may depart from the specification. + :param check_request_payload: If :data:`False`, + :code:`resource` is expected to be a dict that will be passed as-is in the request. + This value can be overwritten in methods. + :param check_response_payload: Whether to validate that the response payloads are valid. + If set, the raw payload will be returned. This value can be overwritten in methods. + :param raise_scim_errors: If :data:`True` and the server returned an + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` + exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. + + .. code-block:: python + + from scim2_client.engines.wsgi import WSGISCIMClient + from scim2_models import Group, ScimProvider, User + + scim = WSGISCIMClient( + myapp.create_app(), + base_url="http://localhost/scim/v2", + provider=ScimProvider(models=[User, Group]), + ) + + user = scim.create(User(user_name="bjensen@example.com")) + assert user.id + """ + + def __init__( + self, + app: "WSGIApplication", + base_url: str = "http://localhost", + headers: _HeadersT | None = None, + environ: "WSGIEnvironment | None" = None, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.app = app + self.base_url = _check_base_url(base_url) + self.headers = headers + self.environ = environ or {} + + def _stays_under_base_url(self, endpoint: str) -> bool: + return _stays_under_base_url(self.base_url, endpoint) + + def _parse_json(self, response: RawResponse) -> Any: + return cast(InProcessResponse, response).json() + + def request(self, method: str, url: str, **kwargs: Any) -> InProcessResponse: + request = _prepare_request(self.base_url, self.headers, method, url, **kwargs) + return _call_application(self.app, _build_environ(request, self.environ)) diff --git a/tests/engines/test_asgi.py b/tests/engines/test_asgi.py new file mode 100644 index 0000000..327bde0 --- /dev/null +++ b/tests/engines/test_asgi.py @@ -0,0 +1,256 @@ +import asyncio +import json + +import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import User + +from scim2_client.engines.asgi import ASGISCIMClient + +scim2_server = pytest.importorskip("scim2_server") +from scim2_server.applications.asgi import ASGIApplication # noqa: E402 +from scim2_server.memory import AsyncInMemoryStorage # noqa: E402 +from scim2_server.utils import load_default_provider # noqa: E402 + + +def respond(status=200, body=b"", headers=()): + """Return the messages of a response sent in one piece.""" + return [ + {"type": "http.response.start", "status": status, "headers": list(headers)}, + {"type": "http.response.body", "body": body}, + ] + + +async def echo(scope, receive, send): + """Answer the scope of the request, and its body.""" + message = await receive() + seen = { + key: value + for key, value in scope.items() + if key not in ("headers", "raw_path", "query_string") + } + seen["raw_path"] = scope["raw_path"].decode() + seen["query_string"] = scope["query_string"].decode() + seen["headers"] = [ + [name.decode(), value.decode()] for name, value in scope["headers"] + ] + seen["body"] = message["body"].decode() + for message in respond(body=json.dumps(seen).encode()): + await send(message) + + +@pytest.fixture +async def scim_client(): + app = ASGIApplication(AsyncInMemoryStorage(), load_default_provider()) + scim_client = ASGISCIMClient(app, base_url="http://localhost/v2") + await scim_client.discover() + return scim_client + + +async def test_operations_reach_the_application(scim_client): + """Every SCIM operation is served by the application.""" + User = scim_client.get_resource_model("User") + + user = await scim_client.create(User(user_name="bjensen", display_name="Babs")) + assert user.meta.location == f"http://localhost/v2/Users/{user.id}" + assert (await scim_client.query(User, user.id)).display_name == "Babs" + + found = await scim_client.search( + User, + SearchRequest(filter='userName eq "bjensen"', attributes=["userName"]), + ) + assert [resource.id for resource in found.resources] == [user.id] + + replaced = await scim_client.replace( + User(id=user.id, user_name="bjensen", display_name="Barbara") + ) + assert replaced.display_name == "Barbara" + + patch_op = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Bab" + ) + ] + ) + await scim_client.modify(User, user.id, patch_op) + assert (await scim_client.query(User, user.id)).display_name == "Bab" + + await scim_client.delete(User, user.id) + assert (await scim_client.query(User)).total_results == 0 + + +async def test_bulk_requests_reach_the_application(scim_client): + """A bulk request is posted to the application.""" + User = scim_client.get_resource_model("User") + request = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method=BulkOperation.Method.post, + path="/Users", + bulk_id="qwerty", + data=User(user_name="bjensen"), + ) + ] + ) + + response = await scim_client.bulk(request) + + assert response.operations[0].status == 201 + + +async def test_raw_request_returns_the_response_of_the_application(scim_client): + """A raw request returns the status, headers and body of the application.""" + response = await scim_client.request("DELETE", "/Schemas") + + assert response.status_code == 405 + assert response.headers.get("allow") == "GET" + assert response.json()["status"] == "405" + + +async def test_scope_describes_the_request(): + """The scope has the method, path, query and headers of the request, and the body follows.""" + client = ASGISCIMClient(echo, base_url="https://scim.test/scim/v2") + + response = await client.request( + "POST", + "/Users/%C3%A9t%C3%A9", + params={"attributes": "userName"}, + json={"userName": "bjensen"}, + headers={"X-Test": "foo"}, + ) + seen = response.json() + + assert seen["type"] == "http" + assert seen["asgi"] == {"version": "3.0"} + assert seen["method"] == "POST" + assert seen["scheme"] == "https" + assert seen["path"] == "/scim/v2/Users/été" + assert seen["raw_path"] == "/scim/v2/Users/%C3%A9t%C3%A9" + assert seen["query_string"] == "attributes=userName" + assert seen["root_path"] == "" + assert seen["server"] == ["scim.test", 443] + assert seen["headers"] == [ + ["host", "scim.test"], + ["content-type", "application/json"], + ["content-length", "23"], + ["x-test", "foo"], + ] + assert seen["body"] == '{"userName": "bjensen"}' + + +async def test_explicit_port_is_the_server_port(): + """The port of the base URL is the port of the server.""" + client = ASGISCIMClient(echo, base_url="http://scim.test:8080") + + seen = (await client.request("GET", "/Users")).json() + + assert seen["server"] == ["scim.test", 8080] + assert seen["body"] == "" + + +async def test_scope_keys_are_added_to_every_request(): + """The scope of the client is added to the scope of every request.""" + client = ASGISCIMClient(echo, scope={"user": "admin"}) + + assert (await client.request("GET", "/Users")).json()["user"] == "admin" + + +async def test_default_headers_are_sent_with_scim_operations(): + """The default headers reach the application with every SCIM operation.""" + seen = {} + + async def app(scope, receive, send): + seen["headers"] = dict(scope["headers"]) + for message in respond(status=204): + await send(message) + + client = ASGISCIMClient( + app, + headers={"Authorization": "Bearer token"}, + provider=ScimProvider(models=[User]), + ) + await client.delete(User, "1") + + assert seen["headers"][b"authorization"] == b"Bearer token" + + +async def test_body_sent_in_several_parts_is_joined(): + """The parts of a body are joined, and the response headers are read.""" + + async def app(scope, receive, send): + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/scim+json")], + } + ) + await send({"type": "http.response.body", "body": b"foo", "more_body": True}) + await send({"type": "http.response.body", "more_body": True}) + await send({"type": "http.response.body", "body": b"bar"}) + + response = await ASGISCIMClient(app).request("GET", "/Users") + + assert response.content == b"foobar" + assert response.headers.get("Content-Type") == "application/scim+json" + + +async def test_disconnection_is_received_once_the_response_is_sent(): + """An application listening for the disconnection is not interrupted before its response is sent.""" + events = [] + + async def app(scope, receive, send): + await receive() + + async def listen_for_disconnect(): + message = await receive() + events.append(message["type"]) + + listener = asyncio.create_task(listen_for_disconnect()) + await send({"type": "http.response.start", "status": 200}) + await asyncio.sleep(0) + events.append("body") + await send({"type": "http.response.body", "body": b"foo"}) + await listener + + response = await ASGISCIMClient(app).request("GET", "/Users") + + assert response.content == b"foo" + assert events == ["body", "http.disconnect"] + + +async def test_exception_of_the_application_reaches_the_caller(): + """An exception raised by the application is not turned into a response.""" + + async def app(scope, receive, send): + raise ZeroDivisionError() + + with pytest.raises(ZeroDivisionError): + await ASGISCIMClient(app).request("GET", "/Users") + + +async def test_application_returning_without_response_is_refused(): + """An application that returns before its response is sent does not follow ASGI.""" + + async def app(scope, receive, send): + await send({"type": "http.response.start", "status": 200}) + await send({"type": "http.response.body", "body": b"foo", "more_body": True}) + + with pytest.raises(RuntimeError, match="returned before sending its response"): + await ASGISCIMClient(app).request("GET", "/Users") + + +async def test_body_before_the_response_start_is_refused(): + """An application cannot send a body before the status of the response.""" + + async def app(scope, receive, send): + await send({"type": "http.response.body", "body": b"foo"}) + + with pytest.raises(RuntimeError, match="body before starting the response"): + await ASGISCIMClient(app).request("GET", "/Users") diff --git a/tests/engines/test_inprocess.py b/tests/engines/test_inprocess.py new file mode 100644 index 0000000..a257529 --- /dev/null +++ b/tests/engines/test_inprocess.py @@ -0,0 +1,135 @@ +import pytest + +from scim2_client.engines.inprocess import InProcessResponse +from scim2_client.engines.inprocess import _build_url +from scim2_client.engines.inprocess import _check_base_url +from scim2_client.engines.inprocess import _prepare_request + +BASE_URL = "http://scim.test/scim/v2" + + +def test_response_text_is_decoded_as_utf8(): + """The body is read as UTF-8, and undecodable bytes are replaced.""" + response = InProcessResponse(200, content="é".encode() + b"\xff") + + assert response.text == "é�" + + +def test_response_json_decodes_the_body(): + """The body is decoded as JSON, as httpx does.""" + response = InProcessResponse(200, content=b'{"id": "1"}') + + assert response.json() == {"id": "1"} + + +def test_response_headers_are_case_insensitive(): + """A header is found whatever the case of its name.""" + response = InProcessResponse(200) + response.headers["Content-Type"] = "application/scim+json" + + assert response.headers.get("content-type") == "application/scim+json" + + +@pytest.mark.parametrize("base_url", ["/scim/v2", "scim.test/scim/v2", ""]) +def test_relative_base_url_is_refused(base_url): + """The base URL gives the host and the scheme of the requests, so it must be absolute.""" + with pytest.raises(ValueError, match="is not absolute"): + _check_base_url(base_url) + + +@pytest.mark.parametrize( + "base_url,url,expected", + [ + (BASE_URL, "/Users", "http://scim.test/scim/v2/Users"), + (BASE_URL, "Users", "http://scim.test/scim/v2/Users"), + (f"{BASE_URL}/", "/Users", "http://scim.test/scim/v2/Users"), + ("http://scim.test", "/Users", "http://scim.test/Users"), + (BASE_URL, "/Users/../Groups", "http://scim.test/scim/v2/Groups"), + (BASE_URL, "/Users?count=1", "http://scim.test/scim/v2/Users?count=1"), + (BASE_URL, "https://other.test/Users", "https://other.test/Users"), + ], +) +def test_url_is_built_under_the_base_url(base_url, url, expected): + """A relative URL is appended to the path of the base URL, an absolute one is kept.""" + assert _build_url(base_url, url).geturl() == expected + + +def test_url_with_control_characters_is_refused(): + """A control character cannot be sent in a request line.""" + with pytest.raises(ValueError, match="control characters"): + _build_url(BASE_URL, "/Users\r\nX-Injected: 1") + + +def test_params_are_appended_to_the_query_of_the_url(): + """The query parameters extend the query string already in the URL.""" + request = _prepare_request( + BASE_URL, None, "GET", "/Users?count=1", params={"startIndex": 2} + ) + + assert request.url.query == "count=1&startIndex=2" + + +def test_params_make_the_query_of_a_url_without_one(): + """The query parameters are encoded in the query string.""" + request = _prepare_request( + BASE_URL, None, "GET", "/Users", params={"filter": 'userName eq "a b"'} + ) + + assert request.url.query == "filter=userName+eq+%22a+b%22" + + +def test_json_body_is_encoded_with_its_content_type(): + """A JSON body is sent with its type and its length.""" + request = _prepare_request(BASE_URL, None, "post", "/Users", json={"id": "1"}) + + assert request.method == "POST" + assert request.body == b'{"id": "1"}' + assert request.headers == [ + ("Host", "scim.test"), + ("Content-Type", "application/json"), + ("Content-Length", "11"), + ] + + +def test_raw_content_is_sent_as_is(): + """A raw body is sent without content type, so that a test controls it.""" + request = _prepare_request(BASE_URL, None, "POST", "/Users", content=b"not json") + + assert request.body == b"not json" + assert request.headers == [("Host", "scim.test"), ("Content-Length", "8")] + + +def test_json_and_content_cannot_be_both_passed(): + """A request has a single body.""" + with pytest.raises(TypeError, match="either 'json' or 'content'"): + _prepare_request(BASE_URL, None, "POST", "/Users", json={}, content=b"{}") + + +def test_unknown_request_argument_is_refused(): + """An argument the engine does not know is not silently ignored.""" + with pytest.raises(TypeError): + _prepare_request(BASE_URL, None, "GET", "/Users", timeout=1) + + +def test_request_headers_replace_default_headers_of_the_same_name(): + """The headers of a request replace the default headers, whatever the case of their name.""" + request = _prepare_request( + BASE_URL, + [("X-Test", "foo"), ("X-Other", "bar"), ("x-test", "baz")], + "GET", + "/Users", + headers={"x-TEST": "qux"}, + ) + + assert request.headers == [ + ("Host", "scim.test"), + ("X-Other", "bar"), + ("x-TEST", "qux"), + ] + + +def test_host_header_can_be_replaced(): + """The Host header comes from the URL, unless the headers give another one.""" + request = _prepare_request(BASE_URL, {"host": "proxy.test"}, "GET", "/Users") + + assert request.headers == [("host", "proxy.test")] diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py index 9fe6051..c79018b 100644 --- a/tests/engines/test_werkzeug.py +++ b/tests/engines/test_werkzeug.py @@ -20,6 +20,14 @@ from scim2_server.memory import InMemoryStorage # noqa: E402 from scim2_server.utils import load_default_provider # noqa: E402 +pytestmark = pytest.mark.filterwarnings("ignore:'TestSCIMClient' is deprecated") + + +def test_werkzeug_engine_is_deprecated(): + """The werkzeug engine is replaced by the WSGI engine.""" + with pytest.warns(DeprecationWarning, match="WSGISCIMClient"): + TestSCIMClient(Client(None)) + @pytest.fixture def scim_app(): diff --git a/tests/engines/test_wsgi.py b/tests/engines/test_wsgi.py new file mode 100644 index 0000000..dfa6675 --- /dev/null +++ b/tests/engines/test_wsgi.py @@ -0,0 +1,277 @@ +import json +import sys + +import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import Group +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import ResourceType +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import User + +from scim2_client.engines.wsgi import WSGISCIMClient + +scim2_server = pytest.importorskip("scim2_server") +from scim2_server.applications.wsgi import WSGIApplication # noqa: E402 +from scim2_server.memory import InMemoryStorage # noqa: E402 +from scim2_server.utils import load_default_provider # noqa: E402 + + +def echo(environ, start_response): + """Answer the WSGI environment of the request, and its body.""" + body = environ["wsgi.input"].read(int(environ.get("CONTENT_LENGTH") or 0)) + seen = { + key: value for key, value in environ.items() if isinstance(value, str | tuple) + } + seen["body"] = body.decode() + start_response("200 OK", [("Content-Type", "application/json")]) + return [json.dumps(seen).encode()] + + +@pytest.fixture +def scim_client(): + app = WSGIApplication(InMemoryStorage(), load_default_provider()) + scim_client = WSGISCIMClient(app, base_url="http://localhost/v2") + scim_client.discover() + return scim_client + + +def test_operations_reach_the_application(scim_client): + """Every SCIM operation is served by the application.""" + User = scim_client.get_resource_model("User") + + user = scim_client.create(User(user_name="bjensen", display_name="Babs")) + assert user.meta.location == f"http://localhost/v2/Users/{user.id}" + assert scim_client.query(User, user.id).display_name == "Babs" + + found = scim_client.search( + User, + SearchRequest(filter='userName eq "bjensen"', attributes=["userName"]), + ) + assert [resource.id for resource in found.resources] == [user.id] + + replaced = scim_client.replace( + User(id=user.id, user_name="bjensen", display_name="Barbara") + ) + assert replaced.display_name == "Barbara" + + patch_op = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Bab" + ) + ] + ) + scim_client.modify(User, user.id, patch_op) + assert scim_client.query(User, user.id).display_name == "Bab" + + scim_client.delete(User, user.id) + assert scim_client.query(User).total_results == 0 + + +def test_bulk_requests_reach_the_application(scim_client): + """A bulk request is posted to the application.""" + User = scim_client.get_resource_model("User") + request = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method=BulkOperation.Method.post, + path="/Users", + bulk_id="qwerty", + data=User(user_name="bjensen"), + ) + ] + ) + + response = scim_client.bulk(request) + + assert response.operations[0].status == 201 + + +def test_raw_request_returns_the_response_of_the_application(scim_client): + """A raw request returns the status, headers and body of the application.""" + response = scim_client.request("DELETE", "/Schemas") + + assert response.status_code == 405 + assert response.headers.get("allow") == "GET" + assert response.json()["status"] == "405" + + +def test_raw_request_sends_a_raw_body(scim_client): + """A raw body reaches the application as it is.""" + response = scim_client.request( + "POST", + "/Users", + content=b"not json", + headers={"Content-Type": "application/scim+json"}, + ) + + assert response.status_code == 400 + + +def test_environ_describes_the_request(): + """The WSGI environment has the method, path, query, headers and body of the request.""" + client = WSGISCIMClient(echo, base_url="https://scim.test/scim/v2") + + seen = client.request( + "POST", + "/Users/%C3%A9t%C3%A9", + params={"attributes": "userName"}, + json={"userName": "bjensen"}, + headers={"X-Test": "foo"}, + ).json() + + assert seen["REQUEST_METHOD"] == "POST" + assert seen["SCRIPT_NAME"] == "" + assert seen["PATH_INFO"] == "/scim/v2/Users/été".encode().decode("latin-1") + assert seen["QUERY_STRING"] == "attributes=userName" + assert seen["SERVER_NAME"] == "scim.test" + assert seen["SERVER_PORT"] == "443" + assert seen["wsgi.url_scheme"] == "https" + assert seen["HTTP_HOST"] == "scim.test" + assert seen["HTTP_X_TEST"] == "foo" + assert seen["CONTENT_TYPE"] == "application/json" + assert seen["CONTENT_LENGTH"] == "23" + assert "HTTP_CONTENT_TYPE" not in seen + assert seen["body"] == '{"userName": "bjensen"}' + + +def test_explicit_port_is_the_server_port(): + """The port of the base URL is the port of the server.""" + client = WSGISCIMClient(echo, base_url="http://scim.test:8080") + + seen = client.request("GET", "/Users").json() + + assert seen["SERVER_PORT"] == "8080" + assert seen["HTTP_HOST"] == "scim.test:8080" + assert "CONTENT_LENGTH" not in seen + + +def test_repeated_headers_are_joined(): + """Repeated headers are joined with commas (RFC 9110 §5.3).""" + client = WSGISCIMClient(echo, headers=[("X-Test", "foo")]) + + seen = client.request( + "GET", "/Users", headers=[("X-Other", "bar"), ("X-Other", "baz")] + ).json() + + assert seen["HTTP_X_TEST"] == "foo" + assert seen["HTTP_X_OTHER"] == "bar, baz" + + +def test_environ_keys_are_added_to_every_request(): + """The environ of the client is added to the environment of every request.""" + client = WSGISCIMClient(echo, environ={"REMOTE_USER": "admin"}) + + assert client.request("GET", "/Users").json()["REMOTE_USER"] == "admin" + + +def test_default_headers_are_sent_with_scim_operations(): + """The default headers reach the application with every SCIM operation.""" + seen = {} + + def app(environ, start_response): + seen["authorization"] = environ.get("HTTP_AUTHORIZATION") + start_response("204 No Content", []) + return [] + + client = WSGISCIMClient( + app, + headers={"Authorization": "Bearer token"}, + provider=ScimProvider(models=[User]), + ) + client.delete(User, "1") + + assert seen["authorization"] == "Bearer token" + + +def test_exception_of_the_application_reaches_the_caller(): + """An exception raised by the application is not turned into a response.""" + + def app(environ, start_response): + raise ZeroDivisionError() + + client = WSGISCIMClient(app) + + with pytest.raises(ZeroDivisionError): + client.request("GET", "/Users") + + +def test_application_without_response_is_refused(): + """An application that does not call start_response does not follow PEP 3333.""" + + def app(environ, start_response): + return [] + + client = WSGISCIMClient(app) + + with pytest.raises(RuntimeError, match="did not call 'start_response'"): + client.request("GET", "/Users") + + +def test_body_written_and_returned_is_joined(): + """The body written with the write callable comes before the returned body.""" + + def app(environ, start_response): + write = start_response("200 OK", []) + write(b"foo") + return [b"bar", b"baz"] + + client = WSGISCIMClient(app) + + assert client.request("GET", "/Users").content == b"foobarbaz" + + +def test_response_iterable_is_closed(): + """The close method of the response iterable is called (PEP 3333).""" + closed = [] + + class Body: + def __iter__(self): + yield b"foo" + + def close(self): + closed.append(True) + + def app(environ, start_response): + start_response("200 OK", []) + return Body() + + client = WSGISCIMClient(app) + + assert client.request("GET", "/Users").content == b"foo" + assert closed == [True] + + +def test_response_restarted_after_an_error_replaces_the_headers(): + """An application calling start_response again after an error sends the new status.""" + + def app(environ, start_response): + start_response("200 OK", [("X-Test", "foo")]) + try: + raise ZeroDivisionError() + except ZeroDivisionError: + start_response("500 Internal Server Error", [], sys.exc_info()) + return [b"error"] + + client = WSGISCIMClient(app) + response = client.request("GET", "/Users") + + assert response.status_code == 500 + assert response.headers.get("X-Test") is None + + +def test_absolute_endpoint_under_the_base_url_is_requested(): + """An endpoint pointing back to the base URL reaches the application.""" + resource_type = ResourceType.from_resource(Group) + resource_type.endpoint = "http://localhost/v2/Groups" + provider = ScimProvider(models=[Group], resource_types=[resource_type]) + app = WSGIApplication(InMemoryStorage(), load_default_provider()) + client = WSGISCIMClient(app, base_url="http://localhost/v2", provider=provider) + + group = client.create(Group(display_name="admins")) + + assert client.query(Group, group.id).display_name == "admins" diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 5da05ff..0da047e 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -17,14 +17,11 @@ from scim2_models import ScimProvider from scim2_models import ServiceProviderConfig from scim2_models import User -from werkzeug.test import Client -from werkzeug.wrappers import Request -from werkzeug.wrappers import Response from scim2_client import RequestNetworkException from scim2_client.engines.httpx2 import AsyncClient from scim2_client.engines.httpx2 import AsyncSCIMClient -from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.engines.wsgi import WSGISCIMClient USER_OPERATION = BulkOperation[User]( method="POST", @@ -394,20 +391,16 @@ async def bulk(): assert isinstance(asyncio.run(bulk()), BulkResponse) -def test_werkzeug_engine(): - """Test that the werkzeug engine posts bulk requests.""" +def test_wsgi_engine(): + """The WSGI engine posts bulk requests.""" seen = {} - @Request.application - def app(request): - seen["payload"] = request.get_json() - return Response( - json.dumps(RESPONSE_PAYLOAD), - status=200, - content_type="application/scim+json", - ) + def app(environ, start_response): + seen["payload"] = json.load(environ["wsgi.input"]) + start_response("200 OK", [("Content-Type", "application/scim+json")]) + return [json.dumps(RESPONSE_PAYLOAD).encode()] - client = TestSCIMClient(Client(app), provider=ScimProvider(models=[User, Group])) + client = WSGISCIMClient(app, provider=ScimProvider(models=[User, Group])) assert isinstance( client.bulk(BulkRequest[User](operations=[USER_OPERATION])), BulkResponse diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 537b737..27b5d84 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -10,11 +10,13 @@ from werkzeug.test import Client as WerkzeugClient from scim2_client import InvalidServiceDescriptionException +from scim2_client.engines.asgi import ASGISCIMClient from scim2_client.engines.httpx2 import AsyncClient from scim2_client.engines.httpx2 import AsyncSCIMClient from scim2_client.engines.httpx2 import Client from scim2_client.engines.httpx2 import SyncSCIMClient from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.engines.wsgi import WSGISCIMClient BASE_URL = "https://scim.example.com/tenantA/scim/v2" @@ -56,12 +58,23 @@ def async_httpx2_client(endpoint): return AsyncSCIMClient(AsyncClient(base_url=BASE_URL), provider=provider(endpoint)) +def wsgi_client(endpoint): + return WSGISCIMClient(None, base_url=BASE_URL, provider=provider(endpoint)) + + +def asgi_client(endpoint): + return ASGISCIMClient(None, base_url=BASE_URL, provider=provider(endpoint)) + + def werkzeug_client(endpoint): - return TestSCIMClient(WerkzeugClient(None), provider=provider(endpoint)) + with pytest.warns(DeprecationWarning): + return TestSCIMClient(WerkzeugClient(None), provider=provider(endpoint)) @pytest.mark.parametrize("endpoint", FOREIGN_ENDPOINTS) -@pytest.mark.parametrize("make_client", [httpx2_client, async_httpx2_client]) +@pytest.mark.parametrize( + "make_client", [httpx2_client, async_httpx2_client, wsgi_client, asgi_client] +) def test_endpoint_leading_away_from_the_base_url_is_refused(make_client, endpoint): """A server cannot send the requests, and their credentials, anywhere but under the base URL.""" client = make_client(endpoint) @@ -73,7 +86,9 @@ def test_endpoint_leading_away_from_the_base_url_is_refused(make_client, endpoin @pytest.mark.parametrize("endpoint", OWN_ENDPOINTS) -@pytest.mark.parametrize("make_client", [httpx2_client, async_httpx2_client]) +@pytest.mark.parametrize( + "make_client", [httpx2_client, async_httpx2_client, wsgi_client, asgi_client] +) def test_endpoint_under_the_base_url_is_used(make_client, endpoint): """Relative endpoints and absolute ones pointing back to the base URL are both accepted.""" client = make_client(endpoint) @@ -108,7 +123,7 @@ def test_endpoint_is_refused_without_a_base_url(): client.resource_endpoint(User) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client, werkzeug_client]) def test_resource_type_without_endpoint_is_refused(make_client): """A resource type the server published without endpoint cannot be requested.""" client = make_client(None) diff --git a/tests/test_etags.py b/tests/test_etags.py index c9bd111..eb76fcc 100644 --- a/tests/test_etags.py +++ b/tests/test_etags.py @@ -10,13 +10,11 @@ from scim2_models import ScimProvider from scim2_models import ServiceProviderConfig from scim2_models import User -from werkzeug.test import Client -from werkzeug.wrappers import Request from werkzeug.wrappers import Response from scim2_client.engines.httpx2 import AsyncClient from scim2_client.engines.httpx2 import AsyncSCIMClient -from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.engines.wsgi import WSGISCIMClient from scim2_client.errors import UnexpectedStatusCodeException VERSION = 'W/"3694e05e9dff590"' @@ -252,17 +250,17 @@ def test_precondition_failed(httpserver, etag_client, versioned_user): assert exc_info.value.status == 412 -def test_werkzeug_engine_sends_if_match(versioned_user): - """The werkzeug engine forwards the conditional header to the application.""" +def test_wsgi_engine_sends_if_match(versioned_user): + """The WSGI engine forwards the conditional header to the application.""" seen = {} - @Request.application - def app(request): - seen["if_match"] = request.headers.get("If-Match") - return Response(status=204, content_type="application/scim+json") + def app(environ, start_response): + seen["if_match"] = environ.get("HTTP_IF_MATCH") + start_response("204 No Content", [("Content-Type", "application/scim+json")]) + return [] - client = TestSCIMClient( - Client(app), + client = WSGISCIMClient( + app, provider=ScimProvider( models=[User], config=ServiceProviderConfig(etag=ETag(supported=True)) ), @@ -376,17 +374,17 @@ def test_unsolicited_not_modified(httpserver, etag_client): assert etag_client.query(User) is None -def test_werkzeug_engine_sends_if_none_match(versioned_user): - """The werkzeug engine forwards the conditional header to the application.""" +def test_wsgi_engine_sends_if_none_match(versioned_user): + """The WSGI engine forwards the conditional header to the application.""" seen = {} - @Request.application - def app(request): - seen["if_none_match"] = request.headers.get("If-None-Match") - return Response(status=304) + def app(environ, start_response): + seen["if_none_match"] = environ.get("HTTP_IF_NONE_MATCH") + start_response("304 Not Modified", []) + return [] - client = TestSCIMClient( - Client(app), + client = WSGISCIMClient( + app, provider=ScimProvider( models=[User], config=ServiceProviderConfig(etag=ETag(supported=True)) ), diff --git a/tests/test_hostile_responses.py b/tests/test_hostile_responses.py index 3b44441..1b8ab97 100644 --- a/tests/test_hostile_responses.py +++ b/tests/test_hostile_responses.py @@ -10,8 +10,6 @@ from scim2_models import SCIMException from scim2_models import ScimProvider from scim2_models import User -from werkzeug.test import Client as WerkzeugClient -from werkzeug.wrappers import Response as WerkzeugResponse from scim2_client import InvalidServiceDescriptionException from scim2_client import ResponsePayloadValidationException @@ -21,7 +19,7 @@ from scim2_client.engines.httpx2 import AsyncSCIMClient from scim2_client.engines.httpx2 import Client from scim2_client.engines.httpx2 import SyncSCIMClient -from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.engines.wsgi import WSGISCIMClient BASE_URL = "https://scim.example.com/scim/v2" ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" @@ -66,6 +64,13 @@ ), ] + +def discover(client): + """Discover the server with a client that knows nothing of it yet.""" + app = client.client if isinstance(client, SyncSCIMClient) else client.app + return type(client)(app, provider=ScimProvider()).discover() + + CALLS = [ pytest.param(lambda client: client.query(User, "1"), id="query"), pytest.param(lambda client: client.query(User), id="search"), @@ -74,10 +79,7 @@ lambda client: client.replace(User(id="1", user_name="bob")), id="replace" ), pytest.param(lambda client: client.delete(User, "1"), id="delete"), - pytest.param( - lambda client: type(client)(client.client, provider=ScimProvider()).discover(), - id="discover", - ), + pytest.param(discover, id="discover"), ] @@ -101,14 +103,17 @@ def httpx2_client(body, status=200, **kwargs): return SyncSCIMClient(http_client, **{"provider": provider(), **kwargs}) -def werkzeug_client(body, status=200, **kwargs): - app = WerkzeugResponse(body, status=status, content_type="application/scim+json") - return TestSCIMClient(WerkzeugClient(app), **{"provider": provider(), **kwargs}) +def wsgi_client(body, status=200, **kwargs): + def app(environ, start_response): + start_response(f"{status} -", [("Content-Type", "application/scim+json")]) + return [body.encode() if isinstance(body, str) else body] + + return WSGISCIMClient(app, **{"provider": provider(), **kwargs}) @pytest.mark.parametrize("body", UNDECODABLE_BODIES) @pytest.mark.parametrize("call", CALLS) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client]) def test_undecodable_body_is_an_unexpected_content_format(make_client, call, body): """A body the JSON decoder chokes on is reported as a body that is not JSON.""" with pytest.raises(UnexpectedContentFormatException): @@ -117,7 +122,7 @@ def test_undecodable_body_is_an_unexpected_content_format(make_client, call, bod @pytest.mark.parametrize("body", DEEPLY_NESTED_BODIES) @pytest.mark.parametrize("call", CALLS) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client]) def test_deeply_nested_body_is_a_response_error(make_client, call, body): """A body nested very deep is reported as a SCIM response error. @@ -129,7 +134,7 @@ def test_deeply_nested_body_is_a_response_error(make_client, call, body): @pytest.mark.parametrize("call", CALLS) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client]) def test_decoder_recursion_error_is_an_unexpected_content_format( make_client, call, monkeypatch ): @@ -146,7 +151,7 @@ def exceed_the_recursion_limit(*args, **kwargs): @pytest.mark.parametrize("body", NOT_OBJECT_BODIES) @pytest.mark.parametrize("call", CALLS) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client]) def test_body_that_is_not_an_object_is_refused(make_client, call, body): """A SCIM message is a JSON object, whatever JSON value the server sends.""" with pytest.raises(UnexpectedContentFormatException, match="is not a JSON object"): @@ -165,7 +170,7 @@ def test_error_status_with_a_body_that_is_not_an_object_is_refused(body, status) @pytest.mark.parametrize("body", INVALID_SCHEMAS_BODIES) @pytest.mark.parametrize("call", CALLS) -@pytest.mark.parametrize("make_client", [httpx2_client, werkzeug_client]) +@pytest.mark.parametrize("make_client", [httpx2_client, wsgi_client]) def test_schemas_that_are_not_a_list_of_strings_are_refused(make_client, call, body): """The schemas select the model of the payload, so they must be URIs.""" with pytest.raises( diff --git a/tests/test_typing.py b/tests/test_typing.py index 2f3e0f0..481a4be 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -16,10 +16,9 @@ from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig from scim2_models import User -from werkzeug.test import Client from scim2_client import BaseAsyncSCIMClient -from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.engines.wsgi import WSGISCIMClient scim2_server = pytest.importorskip("scim2_server") from scim2_server.applications.wsgi import WSGIApplication # noqa: E402 @@ -30,7 +29,7 @@ @pytest.fixture -def client() -> TestSCIMClient: +def client() -> WSGISCIMClient: """Build a client with the scim2-models classes, so it returns instances of them.""" app = WSGIApplication(InMemoryStorage(), load_default_provider()) provider = ScimProvider( @@ -40,19 +39,19 @@ def client() -> TestSCIMClient: ResourceType.from_resource(Group), ], ) - scim_client = TestSCIMClient(Client(app), provider=provider) + scim_client = WSGISCIMClient(app, provider=provider) scim_client.discover() return scim_client @pytest.fixture -def user(client: TestSCIMClient) -> User[Any]: +def user(client: WSGISCIMClient) -> User[Any]: created = client.create(User[Any](user_name="bjensen")) assert isinstance(created, User) return created -def test_query_with_a_model(client: TestSCIMClient, user: User[Any]) -> None: +def test_query_with_a_model(client: WSGISCIMClient, user: User[Any]) -> None: """A model gives a resource with an id, and a list of resources without.""" assert user.id found = assert_type(client.query(User[Any], user.id), User[Any] | Raw) @@ -69,13 +68,13 @@ def test_query_with_a_model(client: TestSCIMClient, user: User[Any]) -> None: assert isinstance(users, ListResponse) -def test_query_with_a_resource_object(client: TestSCIMClient, user: User[Any]) -> None: +def test_query_with_a_resource_object(client: WSGISCIMClient, user: User[Any]) -> None: """A resource object gives a resource of its own type.""" found = assert_type(client.query(user), User[Any] | Raw) assert isinstance(found, User) -def test_query_with_a_resource_type(client: TestSCIMClient, user: User[Any]) -> None: +def test_query_with_a_resource_type(client: WSGISCIMClient, user: User[Any]) -> None: """A resource type gives untyped resources.""" assert user.id found = assert_type(client.query("User", user.id), Resource[Any] | Raw) @@ -87,7 +86,7 @@ def test_query_with_a_resource_type(client: TestSCIMClient, user: User[Any]) -> assert isinstance(users, ListResponse) -def test_query_server_description(client: TestSCIMClient) -> None: +def test_query_server_description(client: WSGISCIMClient) -> None: """The service provider configuration is a single object, the schemas a list.""" config = assert_type( client.query(ServiceProviderConfig), ServiceProviderConfig | Raw @@ -98,7 +97,7 @@ def test_query_server_description(client: TestSCIMClient) -> None: assert isinstance(schemas, ListResponse) -def test_search(client: TestSCIMClient, user: User[Any]) -> None: +def test_search(client: WSGISCIMClient, user: User[Any]) -> None: """A search gives a list of the model it is called with.""" request = SearchRequest[Any].model_validate({"filter": 'userName eq "bjensen"'}) users = assert_type( @@ -110,7 +109,7 @@ def test_search(client: TestSCIMClient, user: User[Any]) -> None: assert isinstance(everything, ListResponse) -def test_create_and_replace(client: TestSCIMClient, user: User[Any]) -> None: +def test_create_and_replace(client: WSGISCIMClient, user: User[Any]) -> None: """A resource object or a model gives a resource of the same type.""" created = assert_type( client.create(User[Any], {"userName": "alice"}), User[Any] | Raw From 2f9a626832796feb22c65363f97825ab66213b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 6 Oct 2026 14:04:24 +0200 Subject: [PATCH 2/2] refactor: make the httpx2 engine helpers private --- scim2_client/engines/httpx2.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scim2_client/engines/httpx2.py b/scim2_client/engines/httpx2.py index ab5bdca..61a3c39 100644 --- a/scim2_client/engines/httpx2.py +++ b/scim2_client/engines/httpx2.py @@ -15,7 +15,7 @@ from scim2_client.errors import RequestNetworkException -def stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool: +def _stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool: """Tell whether the URL httpx2 builds for an endpoint has the origin and the path prefix of the base URL. The URL is built as it will be sent, so an absolute endpoint pointing back @@ -34,7 +34,7 @@ def stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool: @contextmanager -def handle_request_error() -> Iterator[None]: +def _handle_request_error() -> Iterator[None]: try: yield @@ -68,13 +68,13 @@ def __init__(self, client: Client, *args: Any, **kwargs: Any) -> None: self.client = client def _stays_under_base_url(self, endpoint: str) -> bool: - return stays_under_base_url(self.client, endpoint) + return _stays_under_base_url(self.client, endpoint) def _parse_json(self, response: RawResponse) -> Any: return cast(Response, response).json() def request(self, method: str, url: str, **kwargs: Any) -> Response: - with handle_request_error(): + with _handle_request_error(): return self.client.request(method, url, **kwargs) @@ -103,11 +103,11 @@ def __init__(self, client: AsyncClient, *args: Any, **kwargs: Any) -> None: self.client = client def _stays_under_base_url(self, endpoint: str) -> bool: - return stays_under_base_url(self.client, endpoint) + return _stays_under_base_url(self.client, endpoint) def _parse_json(self, response: RawResponse) -> Any: return cast(Response, response).json() async def request(self, method: str, url: str, **kwargs: Any) -> Response: - with handle_request_error(): + with _handle_request_error(): return await self.client.request(method, url, **kwargs)