Coverage for src / lexigram / ai / relay / gateway / credentials.py: 98%
44 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
1"""Per-channel upstream credential injection for the relay gateway.
3Provides `RelayChannelCredentialProvider` (the contract a host
4implements against its own credential store), `NullChannelCredentialProvider`
5(the default no-op implementation), and `CredentialInjectingHTTPClient`
6(a decorator that merges resolved headers into every upstream call).
8The gateway package never sees a real credential value: it only forwards
9opaque headers a host chose to inject based on the active channel name.
10"""
12from __future__ import annotations
14from collections.abc import Mapping
15from typing import Any, Protocol, runtime_checkable
17from lexigram.contracts.exceptions import InfrastructureError
18from lexigram.contracts.web import HTTPClientProtocol, HttpResponse
20__all__ = [
21 "CredentialInjectingHTTPClient",
22 "NullChannelCredentialProvider",
23 "RelayChannelCredentialProvider",
24]
27@runtime_checkable
28class RelayChannelCredentialProvider(Protocol):
29 """Resolve per-channel upstream credential headers.
31 Implementations look up whatever a host stores (env, secrets
32 manager, database) and return HTTP headers to merge into the
33 outbound upstream call. They never receive request payloads and
34 are resolved once per upstream call by channel name only.
35 """
37 async def headers_for(self, channel_name: str) -> Mapping[str, str]: ...
40class NullChannelCredentialProvider:
41 """No-op credential provider; keeps behavior unchanged by default."""
43 async def headers_for(self, channel_name: str) -> Mapping[str, str]:
44 """Return no headers for any channel.
46 Args:
47 channel_name: The active channel name (ignored).
49 Returns:
50 An empty header mapping.
51 """
52 return {}
55class CredentialInjectingHTTPClient:
56 """Wrap an ``HTTPClientProtocol`` and merge per-channel headers.
58 The decorator pops ``channel_name`` from the outbound call's kwargs
59 (defaulting to ``""`` when absent, e.g. calls made outside the
60 gateway), asks the credential provider for the channel's headers,
61 merges them under the caller-supplied ``headers`` (provider headers
62 take precedence on key collision), and delegates to the wrapped
63 client. All other ``HTTPClientProtocol`` methods delegate unchanged.
65 Provider lookup failures are raised as a generic
66 ``InfrastructureError`` so the gateway's upstream adapter classifies
67 them as ``UPSTREAM_FAILED``; header values themselves are never
68 logged or echoed into exceptions.
69 """
71 def __init__(
72 self,
73 wrapped: HTTPClientProtocol,
74 provider: RelayChannelCredentialProvider | None = None,
75 ) -> None:
76 """Bind the decorator to a client and a credential provider.
78 Args:
79 wrapped: The ``HTTPClientProtocol`` implementation driving
80 the actual outbound request.
81 provider: Credential provider for the outbound calls. When
82 omitted, ``NullChannelCredentialProvider`` is used and
83 no headers are ever injected.
84 """
85 self._wrapped = wrapped
86 self._provider = provider or NullChannelCredentialProvider()
88 @property
89 def wrapped(self) -> HTTPClientProtocol:
90 """Return the wrapped client."""
91 return self._wrapped
93 async def start(self) -> None:
94 """Start the wrapped client."""
95 await self._wrapped.start()
97 async def stop(self) -> None:
98 """Stop the wrapped client."""
99 await self._wrapped.stop()
101 async def request(self, method: str, url: str, **kwargs: Any) -> HttpResponse:
102 """Inject credential headers, then delegate to the wrapped client.
104 Args:
105 method: HTTP method (GET, POST, PUT, ...).
106 url: Request URL.
107 **kwargs: Additional options passed to the wrapped client,
108 including ``channel_name`` and ``headers``.
110 Returns:
111 The wrapped client's response.
113 Raises:
114 InfrastructureError: The credential provider failed to
115 resolve headers for the active channel.
116 """
117 channel_name = kwargs.pop("channel_name", "")
118 try:
119 credential_headers = await self._provider.headers_for(channel_name)
120 except Exception as exc:
121 raise InfrastructureError("credential lookup failed") from exc
122 headers = dict(kwargs.pop("headers", None) or {})
123 headers.update(credential_headers)
124 kwargs["headers"] = headers
125 return await self._wrapped.request(method, url, **kwargs)
127 async def get(self, url: str, **kwargs: Any) -> HttpResponse:
128 """Send a GET through the wrapped client."""
129 return await self._wrapped.get(url, **kwargs)
131 async def post(self, url: str, **kwargs: Any) -> HttpResponse:
132 """Send a POST through the wrapped client."""
133 return await self._wrapped.post(url, **kwargs)
135 async def put(self, url: str, **kwargs: Any) -> HttpResponse:
136 """Send a PUT through the wrapped client."""
137 return await self._wrapped.put(url, **kwargs)
139 async def delete(self, url: str, **kwargs: Any) -> HttpResponse:
140 """Send a DELETE through the wrapped client."""
141 return await self._wrapped.delete(url, **kwargs)
143 async def patch(self, url: str, **kwargs: Any) -> HttpResponse:
144 """Send a PATCH through the wrapped client."""
145 return await self._wrapped.patch(url, **kwargs)
147 async def head(self, url: str, **kwargs: Any) -> HttpResponse:
148 """Send a HEAD through the wrapped client."""
149 return await self._wrapped.head(url, **kwargs)