feat(story-2.4): auth coercion as middleware#16
Merged
Conversation
Adds an `auth=` parameter to AsyncClient.__init__ and with_options that accepts str | Callable[[], str | Awaitable[str]] | Middleware | None. String → bearer middleware; zero-arg callable → token-provider middleware called per request; Middleware → passthrough; None → no auth middleware. Resolves the archive AC's ambiguities: - Dispatch by inspect.signature arity (0 → token provider, 2 → Middleware passthrough, else TypeError). - Skip if Authorization header already present (user middleware / per-call header wins). - with_options(auth=...) supported via the keyword allowlist; AsyncClient tracks _user_middleware and _auth separately so with_options(middleware=) preserves auth. - Auth middleware appended at the END of the user middleware list (so it runs just before the transport — "second-to-innermost" per archive). - AuthValue type alias public; bearer helpers internal. Bearer middleware uses @before_request from Story 2-2 for free repr + established decorator lifecycle. Tokens never leak via repr. Out of scope: OAuth flows, refresh tokens, mTLS, signature schemes (HMAC, AWS Sigv4), per-call auth= override on HTTP methods. 19 new tests bring the suite from 273 → 292. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/httpware/_internal/auth.py with: - AuthValue: TypeAlias = str | Callable[[], str | Awaitable[str]] | Middleware | None - _normalize_auth(value): coerces into Middleware | None via signature-arity dispatch (0 → token provider, 2 → Middleware passthrough, else TypeError) - _bearer(token): static bearer middleware via @before_request - _bearer_from_provider(provider): dynamic bearer middleware via @before_request with sync/async detection via inspect.isawaitable - _has_authorization(request): case-insensitive skip-check Ten tests cover: None passthrough, string→bearer happy path and skip-if-present, sync and async callable providers, callable skip-if-present, provider called per request (no caching), Middleware identity passthrough, 1-arg callable raises TypeError, non-callable raises TypeError. No __all__ (project convention). 100% line coverage on the new module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…middleware/_auth Adds the `auth: AuthValue = None` keyword param. Coerces via _normalize_auth and appends the resulting middleware (if any) at the END of the user-supplied middleware list — so it runs just before the transport. Introduces two new private attrs on AsyncClient: - _user_middleware: the user's tuple, EXCLUDING the auth middleware - _auth: the raw AuthValue (so with_options can recompose) ClientConfig.middleware continues to hold the COMPOSED list (what actually runs), keeping the existing wiring contract. Three new construction tests verify: no auth → empty composed; string auth → 1-element composed; user middleware + auth → 3-element composed with user entries at positions 0,1 and auth at position 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tests confirm the auth middleware fires through the full
AsyncClient → transport path:
- string auth attaches Authorization: Bearer <token> on the transport's
observed request
- per-call headers={"Authorization": ...} wins over the auth= param
(skip-if-present rule)
- callable auth invokes the provider once per AsyncClient.get(...) call
No production code changes; Task 2 wired the integration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…are/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>
Consumers writing thin wrappers around AsyncClient construction need to type-annotate their own `auth=` parameter; without a public symbol the only options are duplicating the union or importing from `_internal`. Re-export `AuthValue` from `httpware._internal.auth` and add it to `__all__`. `_normalize_auth` stays internal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI ruff format check flagged 4 files where the implementer subagents ran ruff check but not ruff format. Apply the formatter. ty also caught test_one_arg_callable_raises_typeerror — passing a 1-arg lambda is the whole point of the test, so the existing noqa gets paired with ty: ignore[invalid-argument-type]. Also commits the Story 2-4 plan + the spec tweak that clarified the __all__ ordering rule (the previous wording compared 'A' < 'A' which was incoherent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AsyncClient(auth=...)acceptsstr | Callable[[], str | Awaitable[str]] | Middleware | Noneand coerces it to middleware appended at the innermost position (just outside transport) so user middleware observes the pre-auth request and the transport sees the post-auth request.Bearerinjector; 0-arg callable (sync or async) → per-request token-provider (no caching);Middleware→ identity passthrough;None→ no auth middleware added. ExistingAuthorizationheaders are preserved (case-insensitive skip-if-present, never overwrites).with_options(auth=...)extends the override allowlist;_from_viewnow tracksuser_middleware=andauth=so view clients can themselves callwith_optionsand correctly recompose.AuthValueis re-exported at the top of thehttpwarepackage so consumers can type-annotate their own wrapper signatures.Test plan
uv run pytest tests/test_internal_auth.py -v— 10 unit tests on_normalize_auth(None/str/sync provider/async provider/Middleware/skip-if-present for both static + dynamic / 1-arg TypeError / non-callable TypeError / provider invoked per request)uv run pytest tests/test_client_construction.py -v— construction withauth=None,auth="tok", andauth=combined withmiddleware=uv run pytest tests/test_client_methods.py -v— end-to-end Authorization wire format, per-call header wins overauth=, callable provider invoked per requestuv run pytest tests/test_client_middleware_wiring.py -v— auth runs INSIDE user middleware (user sees pre-auth request, transport sees post-auth);with_options(auth=...)replaces in view;with_options(middleware=...)keeps existing authuv run pytest tests/test_public_api.py -v—AuthValueis inhttpware.__all__and importablejust test— 295 passed, 1 deselected, 100% line coveragejust lint-ci— ruff + ty cleanOut of scope (deferred): OAuth flows, refresh tokens, mTLS, signature schemes (HMAC, AWS Sigv4), custom auth-header names. Empty-string token currently produces
Authorization: Bearer(trailing space); follow-up.🤖 Generated with Claude Code