| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- from __future__ import annotations
- import json
- import struct
- from http import HTTPStatus
- from typing import TYPE_CHECKING, Any, TypeVar
- from ._codec import CODEC_NAME_JSON, Codec
- from ._compression import IdentityCompression, negotiate_compression
- from ._envelope import EnvelopeReader, EnvelopeWriter
- from ._protocol import (
- ConnectWireError,
- HTTPException,
- host_to_server_address,
- url_to_server_address,
- )
- from ._response_metadata import handle_response_trailers
- from ._version import __version__
- from .code import Code
- from .errors import ConnectError
- from .method import IdempotencyLevel, MethodInfo
- from .request import Headers, RequestContext
- if TYPE_CHECKING:
- from collections.abc import Mapping
- import pyqwest
- from ._codec import Codec
- from ._compression import Compression
- REQ = TypeVar("REQ")
- RES = TypeVar("RES")
- CONNECT_HEADER_PROTOCOL_VERSION = "connect-protocol-version"
- CONNECT_PROTOCOL_VERSION = "1"
- CONNECT_HEADER_TIMEOUT = "connect-timeout-ms"
- CONNECT_UNARY_CONTENT_TYPE_PREFIX = "application/"
- CONNECT_UNARY_CONTENT_TYPE_JSON = (
- f"{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{CODEC_NAME_JSON}"
- )
- CONNECT_STREAMING_CONTENT_TYPE_PREFIX = "application/connect+"
- CONNECT_UNARY_HEADER_COMPRESSION = "content-encoding"
- CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION = "accept-encoding"
- CONNECT_STREAMING_HEADER_COMPRESSION = "connect-content-encoding"
- CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION = "connect-accept-encoding"
- _DEFAULT_CONNECT_USER_AGENT = f"connectrpc/{__version__}"
- def _normalize_content_type(content_type: str) -> str:
- # content-type can have parameters, most commonly charset. Our supported codecs,
- # binary and JSON are always either non-text or utf-8 and the parameters are not
- # important for matching to a codec. A custom codec could conceivably need to
- # match on parameters, but we will reconsider that if it is ever asked for.
- return content_type.partition(";")[0].strip().lower()
- def codec_name_from_content_type(content_type: str, *, stream: bool) -> str:
- content_type = _normalize_content_type(content_type)
- prefix = (
- CONNECT_STREAMING_CONTENT_TYPE_PREFIX
- if stream
- else CONNECT_UNARY_CONTENT_TYPE_PREFIX
- )
- if content_type.startswith(prefix):
- return content_type[len(prefix) :]
- # Follow connect-go behavior for malformed content type. If the content type misses the prefix,
- # it will still be coincidentally handled.
- return content_type
- class ConnectServerProtocol:
- def create_request_context(
- self,
- method: MethodInfo[REQ, RES],
- http_method: str,
- http_scheme: str,
- headers: Headers,
- client_address: str | None = None,
- ) -> RequestContext[REQ, RES]:
- if method.idempotency_level == IdempotencyLevel.NO_SIDE_EFFECTS:
- if http_method not in ("GET", "POST"):
- raise HTTPException(
- HTTPStatus.METHOD_NOT_ALLOWED, [("allow", "GET, POST")]
- )
- elif http_method != "POST":
- raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED, [("allow", "POST")])
- # We don't require connect-protocol-version header. connect-go provides an option
- # to require it but it's almost never used in practice.
- connect_protocol_version = headers.get(
- CONNECT_HEADER_PROTOCOL_VERSION, CONNECT_PROTOCOL_VERSION
- )
- if connect_protocol_version != CONNECT_PROTOCOL_VERSION:
- raise ConnectError(
- Code.INVALID_ARGUMENT,
- f"connect-protocol-version must be '1': got '{connect_protocol_version}'",
- )
- timeout_header = headers.get(CONNECT_HEADER_TIMEOUT)
- if timeout_header:
- if len(timeout_header) > 10:
- raise ConnectError(
- Code.INVALID_ARGUMENT,
- f"Invalid timeout header: '{timeout_header} has >10 digits",
- )
- try:
- timeout_ms = int(timeout_header)
- except ValueError as e:
- raise ConnectError(
- Code.INVALID_ARGUMENT, f"Invalid timeout header: '{timeout_header}'"
- ) from e
- else:
- timeout_ms = None
- server_address = host_to_server_address(headers.get("host"), http_scheme)
- return RequestContext(
- method=method,
- http_method=http_method,
- request_headers=headers,
- timeout_ms=timeout_ms,
- server_address=server_address,
- client_address=client_address,
- )
- def create_envelope_writer(
- self, codec: Codec[RES, Any], compression: Compression | None
- ) -> EnvelopeWriter[RES]:
- return ConnectEnvelopeWriter(codec, compression)
- def uses_trailers(self) -> bool:
- return False
- def content_type(self, codec: Codec) -> str:
- return f"{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{codec.name()}"
- def compression_header_name(self) -> str:
- return CONNECT_STREAMING_HEADER_COMPRESSION
- def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> str:
- return codec_name_from_content_type(content_type, stream=stream)
- def negotiate_stream_compression(
- self, headers: Headers, compressions: dict[str, Compression]
- ) -> tuple[Compression, Compression]:
- req_compression_name = headers.get(
- CONNECT_STREAMING_HEADER_COMPRESSION, "identity"
- )
- req_compression = (
- compressions.get(req_compression_name) or IdentityCompression()
- )
- accept_compression = headers.get(
- CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION, ""
- )
- resp_compression = negotiate_compression(accept_compression, compressions)
- return req_compression, resp_compression
- class ConnectEnvelopeWriter(EnvelopeWriter):
- def end(self, user_trailers: Headers, error: ConnectWireError | None) -> bytes:
- end_message = {}
- if user_trailers:
- metadata: dict[str, list[str]] = {}
- for key, value in user_trailers.allitems():
- metadata.setdefault(key, []).append(value)
- end_message["metadata"] = metadata
- if error:
- end_message["error"] = error.to_dict()
- data = json.dumps(end_message).encode()
- if self._compression:
- data = self._compression.compress(data)
- return struct.pack(">BI", self._prefix | 0b10, len(data)) + data
- class ConnectClientProtocol:
- def create_request_context(
- self,
- *,
- method: MethodInfo[REQ, RES],
- url: str,
- http_method: str,
- user_headers: Headers | Mapping[str, str] | None,
- timeout_ms: int | None,
- codec: Codec,
- stream: bool,
- accept_compression: str,
- send_compression: Compression | None,
- ) -> RequestContext[REQ, RES]:
- match user_headers:
- case Headers():
- # Copy to prevent modification if user keeps reference
- # TODO: Optimize
- headers = Headers(tuple(user_headers.allitems()))
- case None:
- headers = Headers()
- case _:
- headers = Headers(user_headers)
- if "user-agent" not in headers:
- headers["user-agent"] = _DEFAULT_CONNECT_USER_AGENT
- headers["connect-protocol-version"] = CONNECT_PROTOCOL_VERSION
- compression_header = (
- CONNECT_STREAMING_HEADER_COMPRESSION
- if stream
- else CONNECT_UNARY_HEADER_COMPRESSION
- )
- accept_compression_header = (
- CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION
- if stream
- else CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION
- )
- headers[accept_compression_header] = accept_compression
- if send_compression is not None:
- headers[compression_header] = send_compression.name()
- else:
- headers.pop(compression_header, None)
- headers["content-type"] = (
- f"{CONNECT_STREAMING_CONTENT_TYPE_PREFIX if stream else CONNECT_UNARY_CONTENT_TYPE_PREFIX}{codec.name()}"
- )
- if timeout_ms is not None:
- headers["connect-timeout-ms"] = str(timeout_ms)
- server_address = url_to_server_address(url)
- return RequestContext(
- method=method,
- http_method=http_method,
- request_headers=headers,
- timeout_ms=timeout_ms,
- server_address=server_address,
- )
- def validate_response(
- self, request_codec_name: str, status_code: int, response_content_type: str
- ) -> None:
- response_content_type = _normalize_content_type(response_content_type)
- if status_code != HTTPStatus.OK:
- # Error responses must be JSON-encoded
- if response_content_type == CONNECT_UNARY_CONTENT_TYPE_JSON:
- return
- raise ConnectWireError.from_http_status(status_code).to_exception()
- if not response_content_type.startswith(CONNECT_UNARY_CONTENT_TYPE_PREFIX):
- raise ConnectError(
- Code.UNKNOWN,
- f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{request_codec_name}'",
- )
- response_codec_name = codec_name_from_content_type(
- response_content_type, stream=False
- )
- if response_codec_name == request_codec_name:
- return
- raise ConnectError(
- Code.INTERNAL,
- f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{request_codec_name}'",
- )
- def validate_stream_response(
- self, request_codec_name: str, response_content_type: str
- ) -> None:
- if not response_content_type.startswith(CONNECT_STREAMING_CONTENT_TYPE_PREFIX):
- raise ConnectError(
- Code.UNKNOWN,
- f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{request_codec_name}'",
- )
- response_codec_name = response_content_type[
- len(CONNECT_STREAMING_CONTENT_TYPE_PREFIX) :
- ]
- if response_codec_name != request_codec_name:
- raise ConnectError(
- Code.INTERNAL,
- f"invalid content-type: '{response_content_type}'; expecting '{CONNECT_STREAMING_CONTENT_TYPE_PREFIX}{request_codec_name}'",
- )
- def handle_response_compression(
- self,
- headers: pyqwest.Headers,
- compressions: dict[str, Compression],
- *,
- stream: bool,
- ) -> Compression:
- compression_header = (
- CONNECT_STREAMING_HEADER_COMPRESSION
- if stream
- else CONNECT_UNARY_HEADER_COMPRESSION
- )
- encoding = headers.get(compression_header)
- if not encoding:
- return IdentityCompression()
- res = compressions.get(encoding)
- if not res:
- raise ConnectError(
- Code.INTERNAL,
- f"unknown encoding '{encoding}'; accepted encodings are {', '.join(compressions.keys())}",
- )
- return res
- def create_envelope_reader(
- self,
- message_class: type[RES],
- codec: Codec,
- compression: Compression,
- read_max_bytes: int | None,
- ) -> EnvelopeReader[RES]:
- return ConnectEnvelopeReader(message_class, codec, compression, read_max_bytes)
- class ConnectEnvelopeReader(EnvelopeReader[RES]):
- def handle_end_message(
- self, prefix_byte: int, message_data: bytes | bytearray
- ) -> bool:
- end_stream = prefix_byte & 0b10 != 0
- if not end_stream:
- return False
- end_stream_message: dict = json.loads(message_data)
- metadata = end_stream_message.get("metadata")
- if metadata:
- handle_response_trailers(metadata)
- error = end_stream_message.get("error")
- if error:
- # Most likely a bug in the protocol, handling of unknown code is different for unary
- # and streaming.
- raise ConnectWireError.from_dict(error, 500, Code.UNKNOWN).to_exception()
- return True
|