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

1"""Per-channel upstream credential injection for the relay gateway. 

2 

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). 

7 

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""" 

11 

12from __future__ import annotations 

13 

14from collections.abc import Mapping 

15from typing import Any, Protocol, runtime_checkable 

16 

17from lexigram.contracts.exceptions import InfrastructureError 

18from lexigram.contracts.web import HTTPClientProtocol, HttpResponse 

19 

20__all__ = [ 

21 "CredentialInjectingHTTPClient", 

22 "NullChannelCredentialProvider", 

23 "RelayChannelCredentialProvider", 

24] 

25 

26 

27@runtime_checkable 

28class RelayChannelCredentialProvider(Protocol): 

29 """Resolve per-channel upstream credential headers. 

30 

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 """ 

36 

37 async def headers_for(self, channel_name: str) -> Mapping[str, str]: ... 

38 

39 

40class NullChannelCredentialProvider: 

41 """No-op credential provider; keeps behavior unchanged by default.""" 

42 

43 async def headers_for(self, channel_name: str) -> Mapping[str, str]: 

44 """Return no headers for any channel. 

45 

46 Args: 

47 channel_name: The active channel name (ignored). 

48 

49 Returns: 

50 An empty header mapping. 

51 """ 

52 return {} 

53 

54 

55class CredentialInjectingHTTPClient: 

56 """Wrap an ``HTTPClientProtocol`` and merge per-channel headers. 

57 

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. 

64 

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 """ 

70 

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. 

77 

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() 

87 

88 @property 

89 def wrapped(self) -> HTTPClientProtocol: 

90 """Return the wrapped client.""" 

91 return self._wrapped 

92 

93 async def start(self) -> None: 

94 """Start the wrapped client.""" 

95 await self._wrapped.start() 

96 

97 async def stop(self) -> None: 

98 """Stop the wrapped client.""" 

99 await self._wrapped.stop() 

100 

101 async def request(self, method: str, url: str, **kwargs: Any) -> HttpResponse: 

102 """Inject credential headers, then delegate to the wrapped client. 

103 

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``. 

109 

110 Returns: 

111 The wrapped client's response. 

112 

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) 

126 

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) 

130 

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) 

134 

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) 

138 

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) 

142 

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) 

146 

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)