Skip to content

Commit b8c09c2

Browse files
lesnik512claude
andcommitted
feat(story-2.4): with_options(auth=) + _from_view tracks user_middleware/auth
with_options now accepts `auth: AuthValue | object = _UNSET`. View construction recomposes the chain from the new (or inherited) user middleware + auth value: `with_options(middleware=...)` preserves auth; `with_options(auth=...)` preserves user middleware; both can change together; neither change loses the auth/middleware that wasn't passed. _from_view gains `user_middleware=` and `auth=` keyword params so the view client inherits the raw inputs (not just the composed result) and its own with_options call works correctly. Three new wiring tests cover: auth runs INSIDE user middleware (user sees request before auth header; transport sees it after); with_options replaces auth in the view while preserving the parent; with_options replacing middleware preserves auth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bbc6e80 commit b8c09c2

2 files changed

Lines changed: 91 additions & 3 deletions

File tree

src/httpware/client.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ def with_options(
598598
timeout: Timeout | float | None = _UNSET,
599599
decoder: ResponseDecoder | None = _UNSET,
600600
middleware: Sequence[Middleware] | None = _UNSET,
601+
auth: AuthValue | object = _UNSET,
601602
) -> "AsyncClient":
602603
"""Return a new AsyncClient sharing the same transport with overridden config.
603604
@@ -619,18 +620,46 @@ def with_options(
619620
changes["timeout"] = _normalize_timeout(timeout)
620621
if decoder is not _UNSET:
621622
changes["decoder"] = decoder or PydanticDecoder()
623+
624+
new_user_middleware = self._user_middleware
622625
if middleware is not _UNSET:
623-
changes["middleware"] = tuple(middleware) if middleware is not None else ()
626+
new_user_middleware = tuple(middleware) if middleware is not None else ()
627+
628+
new_auth: AuthValue = self._auth
629+
if auth is not _UNSET:
630+
new_auth = auth # ty: ignore[invalid-assignment]
631+
632+
new_auth_middleware = _normalize_auth(new_auth)
633+
new_composed: tuple[Middleware, ...] = (
634+
new_user_middleware
635+
if new_auth_middleware is None
636+
else (*new_user_middleware, new_auth_middleware)
637+
)
638+
changes["middleware"] = new_composed
624639

625640
new_config = dataclasses.replace(self._config, **changes)
626-
return AsyncClient._from_view(new_config, self._transport)
641+
return AsyncClient._from_view(
642+
new_config,
643+
self._transport,
644+
user_middleware=new_user_middleware,
645+
auth=new_auth,
646+
)
627647

628648
@classmethod
629-
def _from_view(cls, config: ClientConfig, transport: Transport) -> "AsyncClient":
649+
def _from_view(
650+
cls,
651+
config: ClientConfig,
652+
transport: Transport,
653+
*,
654+
user_middleware: tuple[Middleware, ...],
655+
auth: AuthValue,
656+
) -> "AsyncClient":
630657
"""Construct a view sharing an existing transport. Bypasses __init__."""
631658
client = cls.__new__(cls)
632659
client._config = config # noqa: SLF001
633660
client._transport = transport # noqa: SLF001
634661
client._dispatch = compose(config.middleware, transport) # noqa: SLF001
635662
client._owns_transport = False # noqa: SLF001
663+
client._user_middleware = user_middleware # noqa: SLF001
664+
client._auth = auth # noqa: SLF001
636665
return client

tests/test_client_middleware_wiring.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Unit tests for AsyncClient middleware wiring through compose() and with_options."""
22

3+
from collections.abc import Mapping
4+
35
from httpware import AsyncClient, RecordedTransport
46
from httpware.middleware import Middleware, Next
57
from httpware.request import Request
@@ -112,3 +114,60 @@ def decode(self, content: bytes, model: type) -> object: # pragma: no cover #
112114
client = AsyncClient(transport=transport)
113115
view = client.with_options(decoder=new_decoder)
114116
assert view._config.decoder is new_decoder # noqa: SLF001
117+
118+
119+
async def test_auth_runs_inside_user_middleware() -> None:
120+
transport = RecordedTransport(
121+
default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)
122+
)
123+
124+
user_seen_headers: list[Mapping[str, str]] = []
125+
126+
class _UserOuter:
127+
async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
128+
user_seen_headers.append(dict(request.headers))
129+
return await next(request)
130+
131+
client = AsyncClient(transport=transport, middleware=[_UserOuter()], auth="tok")
132+
await client.get("/foo")
133+
134+
# User middleware saw the request BEFORE auth header was applied.
135+
assert "Authorization" not in user_seen_headers[0]
136+
# Transport saw the request WITH the auth header.
137+
assert transport.last_request is not None
138+
assert transport.last_request.headers["Authorization"] == "Bearer tok"
139+
140+
141+
async def test_with_options_auth_replaces_auth_middleware() -> None:
142+
transport = RecordedTransport(
143+
default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)
144+
)
145+
client = AsyncClient(transport=transport, auth="parent")
146+
view = client.with_options(auth="view")
147+
148+
await view.get("/foo")
149+
assert transport.last_request is not None
150+
assert transport.last_request.headers["Authorization"] == "Bearer view"
151+
152+
await client.get("/foo")
153+
assert transport.last_request is not None
154+
assert transport.last_request.headers["Authorization"] == "Bearer parent"
155+
156+
157+
async def test_with_options_middleware_keeps_existing_auth() -> None:
158+
transport = RecordedTransport(
159+
default=Response(status=200, headers={}, content=b"", url="/", elapsed=0.0)
160+
)
161+
162+
class _M:
163+
async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
164+
return await next(request)
165+
166+
m1 = _M()
167+
m2 = _M()
168+
client = AsyncClient(transport=transport, auth="tok", middleware=[m1])
169+
view = client.with_options(middleware=[m2])
170+
171+
await view.get("/foo")
172+
assert transport.last_request is not None
173+
assert transport.last_request.headers["Authorization"] == "Bearer tok"

0 commit comments

Comments
 (0)