Skip to content

feat(story-2.4): auth coercion as middleware#16

Merged
lesnik512 merged 7 commits into
mainfrom
story/2-4-auth-coercion
May 31, 2026
Merged

feat(story-2.4): auth coercion as middleware#16
lesnik512 merged 7 commits into
mainfrom
story/2-4-auth-coercion

Conversation

@lesnik512
Copy link
Copy Markdown
Member

Summary

  • AsyncClient(auth=...) accepts str | Callable[[], str | Awaitable[str]] | Middleware | None and 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.
  • String → static Bearer injector; 0-arg callable (sync or async) → per-request token-provider (no caching); Middleware → identity passthrough; None → no auth middleware added. Existing Authorization headers are preserved (case-insensitive skip-if-present, never overwrites).
  • with_options(auth=...) extends the override allowlist; _from_view now tracks user_middleware= and auth= so view clients can themselves call with_options and correctly recompose. AuthValue is re-exported at the top of the httpware package 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 with auth=None, auth="tok", and auth= combined with middleware=
  • uv run pytest tests/test_client_methods.py -v — end-to-end Authorization wire format, per-call header wins over auth=, callable provider invoked per request
  • uv 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 auth
  • uv run pytest tests/test_public_api.py -vAuthValue is in httpware.__all__ and importable
  • just test — 295 passed, 1 deselected, 100% line coverage
  • just lint-ci — ruff + ty clean

Out 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

lesnik512 and others added 7 commits June 1, 2026 00:14
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>
@lesnik512 lesnik512 self-assigned this May 31, 2026
@lesnik512 lesnik512 merged commit 7855de8 into main May 31, 2026
5 checks passed
@lesnik512 lesnik512 deleted the story/2-4-auth-coercion branch May 31, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant