_protocol_connect.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. from __future__ import annotations
  2. import json
  3. import struct
  4. from http import HTTPStatus
  5. from typing import TYPE_CHECKING, Any, TypeVar
  6. from ._codec import CODEC_NAME_JSON, Codec
  7. from ._compression import IdentityCompression, negotiate_compression
  8. from ._envelope import EnvelopeReader, EnvelopeWriter
  9. from ._protocol import (
  10. ConnectWireError,
  11. HTTPException,
  12. host_to_server_address,
  13. url_to_server_address,
  14. )
  15. from ._response_metadata import handle_response_trailers
  16. from ._version import __version__
  17. from .code import Code
  18. from .errors import ConnectError
  19. from .method import IdempotencyLevel, MethodInfo
  20. from .request import Headers, RequestContext
  21. if TYPE_CHECKING:
  22. from collections.abc import Mapping
  23. import pyqwest
  24. from ._codec import Codec
  25. from ._compression import Compression
  26. REQ = TypeVar("REQ")
  27. RES = TypeVar("RES")
  28. CONNECT_HEADER_PROTOCOL_VERSION = "connect-protocol-version"
  29. CONNECT_PROTOCOL_VERSION = "1"
  30. CONNECT_HEADER_TIMEOUT = "connect-timeout-ms"
  31. CONNECT_UNARY_CONTENT_TYPE_PREFIX = "application/"
  32. CONNECT_UNARY_CONTENT_TYPE_JSON = (
  33. f"{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{CODEC_NAME_JSON}"
  34. )
  35. CONNECT_STREAMING_CONTENT_TYPE_PREFIX = "application/connect+"
  36. CONNECT_UNARY_HEADER_COMPRESSION = "content-encoding"
  37. CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION = "accept-encoding"
  38. CONNECT_STREAMING_HEADER_COMPRESSION = "connect-content-encoding"
  39. CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION = "connect-accept-encoding"
  40. _DEFAULT_CONNECT_USER_AGENT = f"connectrpc/{__version__}"
  41. def _normalize_content_type(content_type: str) -> str:
  42. # content-type can have parameters, most commonly charset. Our supported codecs,
  43. # binary and JSON are always either non-text or utf-8 and the parameters are not
  44. # important for matching to a codec. A custom codec could conceivably need to
  45. # match on parameters, but we will reconsider that if it is ever asked for.
  46. return content_type.partition(";")[0].strip().lower()
  47. def codec_name_from_content_type(content_type: str, *, stream: bool) -> str:
  48. content_type = _normalize_content_type(content_type)
  49. prefix = (
  50. CONNECT_STREAMING_CONTENT_TYPE_PREFIX
  51. if stream
  52. else CONNECT_UNARY_CONTENT_TYPE_PREFIX
  53. )
  54. if content_type.startswith(prefix):
  55. return content_type[len(prefix) :]
  56. # Follow connect-go behavior for malformed content type. If the content type misses the prefix,
  57. # it will still be coincidentally handled.
  58. return content_type
  59. class ConnectServerProtocol:
  60. def create_request_context(
  61. self,
  62. method: MethodInfo[REQ, RES],
  63. http_method: str,
  64. http_scheme: str,
  65. headers: Headers,
  66. client_address: str | None = None,
  67. ) -> RequestContext[REQ, RES]:
  68. if method.idempotency_level == IdempotencyLevel.NO_SIDE_EFFECTS:
  69. if http_method not in ("GET", "POST"):
  70. raise HTTPException(
  71. HTTPStatus.METHOD_NOT_ALLOWED, [("allow", "GET, POST")]
  72. )
  73. elif http_method != "POST":
  74. raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED, [("allow", "POST")])
  75. # We don't require connect-protocol-version header. connect-go provides an option
  76. # to require it but it's almost never used in practice.
  77. connect_protocol_version = headers.get(
  78. CONNECT_HEADER_PROTOCOL_VERSION, CONNECT_PROTOCOL_VERSION
  79. )
  80. if connect_protocol_version != CONNECT_PROTOCOL_VERSION:
  81. raise ConnectError(
  82. Code.INVALID_ARGUMENT,
  83. f"connect-protocol-version must be '1': got '{connect_protocol_version}'",
  84. )
  85. timeout_header = headers.get(CONNECT_HEADER_TIMEOUT)
  86. if timeout_header:
  87. if len(timeout_header) > 10:
  88. raise ConnectError(
  89. Code.INVALID_ARGUMENT,
  90. f"Invalid timeout header: '{timeout_header} has >10 digits",
  91. )
  92. try:
  93. timeout_ms = int(timeout_header)
  94. except ValueError as e:
  95. raise ConnectError(
  96. Code.INVALID_ARGUMENT, f"Invalid timeout header: '{timeout_header}'"
  97. ) from e
  98. else:
  99. timeout_ms = None
  100. server_address = host_to_server_address(headers.get("host"), http_scheme)
  101. return RequestContext(
  102. method=method,
  103. http_method=http_method,
  104. request_headers=headers,
  105. timeout_ms=timeout_ms,
  106. server_address=server_address,
  107. client_address=client_address,
  108. )
  109. def create_envelope_writer(
  110. self, codec: Codec[RES, Any], compression: Compression | None
  111. ) -> EnvelopeWriter[RES]:
  112. return ConnectEnvelopeWriter(codec, compression)
  113. def uses_trailers(self) -> bool:
  114. return False
  115. def content_type(self, codec: Codec) -> str:
  116. return f"{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{codec.name()}"
  117. def compression_header_name(self) -> str:
  118. return CONNECT_STREAMING_HEADER_COMPRESSION
  119. def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> str:
  120. return codec_name_from_content_type(content_type, stream=stream)
  121. def negotiate_stream_compression(
  122. self, headers: Headers, compressions: dict[str, Compression]
  123. ) -> tuple[Compression, Compression]:
  124. req_compression_name = headers.get(
  125. CONNECT_STREAMING_HEADER_COMPRESSION, "identity"
  126. )
  127. req_compression = (
  128. compressions.get(req_compression_name) or IdentityCompression()
  129. )
  130. accept_compression = headers.get(
  131. CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION, ""
  132. )
  133. resp_compression = negotiate_compression(accept_compression, compressions)
  134. return req_compression, resp_compression
  135. class ConnectEnvelopeWriter(EnvelopeWriter):
  136. def end(self, user_trailers: Headers, error: ConnectWireError | None) -> bytes:
  137. end_message = {}
  138. if user_trailers:
  139. metadata: dict[str, list[str]] = {}
  140. for key, value in user_trailers.allitems():
  141. metadata.setdefault(key, []).append(value)
  142. end_message["metadata"] = metadata
  143. if error:
  144. end_message["error"] = error.to_dict()
  145. data = json.dumps(end_message).encode()
  146. if self._compression:
  147. data = self._compression.compress(data)
  148. return struct.pack(">BI", self._prefix | 0b10, len(data)) + data
  149. class ConnectClientProtocol:
  150. def create_request_context(
  151. self,
  152. *,
  153. method: MethodInfo[REQ, RES],
  154. url: str,
  155. http_method: str,
  156. user_headers: Headers | Mapping[str, str] | None,
  157. timeout_ms: int | None,
  158. codec: Codec,
  159. stream: bool,
  160. accept_compression: str,
  161. send_compression: Compression | None,
  162. ) -> RequestContext[REQ, RES]:
  163. match user_headers:
  164. case Headers():
  165. # Copy to prevent modification if user keeps reference
  166. # TODO: Optimize
  167. headers = Headers(tuple(user_headers.allitems()))
  168. case None:
  169. headers = Headers()
  170. case _:
  171. headers = Headers(user_headers)
  172. if "user-agent" not in headers:
  173. headers["user-agent"] = _DEFAULT_CONNECT_USER_AGENT
  174. headers["connect-protocol-version"] = CONNECT_PROTOCOL_VERSION
  175. compression_header = (
  176. CONNECT_STREAMING_HEADER_COMPRESSION
  177. if stream
  178. else CONNECT_UNARY_HEADER_COMPRESSION
  179. )
  180. accept_compression_header = (
  181. CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION
  182. if stream
  183. else CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION
  184. )
  185. headers[accept_compression_header] = accept_compression
  186. if send_compression is not None:
  187. headers[compression_header] = send_compression.name()
  188. else:
  189. headers.pop(compression_header, None)
  190. headers["content-type"] = (
  191. f"{CONNECT_STREAMING_CONTENT_TYPE_PREFIX if stream else CONNECT_UNARY_CONTENT_TYPE_PREFIX}{codec.name()}"
  192. )
  193. if timeout_ms is not None:
  194. headers["connect-timeout-ms"] = str(timeout_ms)
  195. server_address = url_to_server_address(url)
  196. return RequestContext(
  197. method=method,
  198. http_method=http_method,
  199. request_headers=headers,
  200. timeout_ms=timeout_ms,
  201. server_address=server_address,
  202. )
  203. def validate_response(
  204. self, request_codec_name: str, status_code: int, response_content_type: str
  205. ) -> None:
  206. response_content_type = _normalize_content_type(response_content_type)
  207. if status_code != HTTPStatus.OK:
  208. # Error responses must be JSON-encoded
  209. if response_content_type == CONNECT_UNARY_CONTENT_TYPE_JSON:
  210. return
  211. raise ConnectWireError.from_http_status(status_code).to_exception()
  212. if not response_content_type.startswith(CONNECT_UNARY_CONTENT_TYPE_PREFIX):
  213. raise ConnectError(
  214. Code.UNKNOWN,
  215. f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{request_codec_name}'",
  216. )
  217. response_codec_name = codec_name_from_content_type(
  218. response_content_type, stream=False
  219. )
  220. if response_codec_name == request_codec_name:
  221. return
  222. raise ConnectError(
  223. Code.INTERNAL,
  224. f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{request_codec_name}'",
  225. )
  226. def validate_stream_response(
  227. self, request_codec_name: str, response_content_type: str
  228. ) -> None:
  229. if not response_content_type.startswith(CONNECT_STREAMING_CONTENT_TYPE_PREFIX):
  230. raise ConnectError(
  231. Code.UNKNOWN,
  232. f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{request_codec_name}'",
  233. )
  234. response_codec_name = response_content_type[
  235. len(CONNECT_STREAMING_CONTENT_TYPE_PREFIX) :
  236. ]
  237. if response_codec_name != request_codec_name:
  238. raise ConnectError(
  239. Code.INTERNAL,
  240. f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{request_codec_name}'",
  241. )
  242. def handle_response_compression(
  243. self,
  244. headers: pyqwest.Headers,
  245. compressions: dict[str, Compression],
  246. *,
  247. stream: bool,
  248. ) -> Compression:
  249. compression_header = (
  250. CONNECT_STREAMING_HEADER_COMPRESSION
  251. if stream
  252. else CONNECT_UNARY_HEADER_COMPRESSION
  253. )
  254. encoding = headers.get(compression_header)
  255. if not encoding:
  256. return IdentityCompression()
  257. res = compressions.get(encoding)
  258. if not res:
  259. raise ConnectError(
  260. Code.INTERNAL,
  261. f"unknown encoding '{encoding}'; accepted encodings are {', '.join(compressions.keys())}",
  262. )
  263. return res
  264. def create_envelope_reader(
  265. self,
  266. message_class: type[RES],
  267. codec: Codec,
  268. compression: Compression,
  269. read_max_bytes: int | None,
  270. ) -> EnvelopeReader[RES]:
  271. return ConnectEnvelopeReader(message_class, codec, compression, read_max_bytes)
  272. class ConnectEnvelopeReader(EnvelopeReader[RES]):
  273. def handle_end_message(
  274. self, prefix_byte: int, message_data: bytes | bytearray
  275. ) -> bool:
  276. end_stream = prefix_byte & 0b10 != 0
  277. if not end_stream:
  278. return False
  279. end_stream_message: dict = json.loads(message_data)
  280. metadata = end_stream_message.get("metadata")
  281. if metadata:
  282. handle_response_trailers(metadata)
  283. error = end_stream_message.get("error")
  284. if error:
  285. # Most likely a bug in the protocol, handling of unknown code is different for unary
  286. # and streaming.
  287. raise ConnectWireError.from_dict(error, 500, Code.UNKNOWN).to_exception()
  288. return True