From 17c2c1163ec5882a89b3020337a8d241a8a153c6 Mon Sep 17 00:00:00 2001 From: Kevin Wooten Date: Thu, 15 Oct 2026 17:05:51 -0700 Subject: [PATCH 1/2] Preserve whole-body binary HTTPX responses Add response-specific binary decoding and Accept negotiation, preserving empty bodies and typed response metadata. Cover arbitrary media types and document the generator release dependency. --- CHANGELOG.md | 3 + docs/migration.md | 19 ++++ src/sunday/httpx/_transport.py | 17 +-- src/sunday/specs.py | 31 +++++- tests/test_httpx_binary.py | 186 +++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 tests/test_httpx_binary.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc285e0..ff42d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Preserve whole-body binary responses with `ResponseSpec(binary_body=True)`, including empty bodies, JSON/text assets, + and arbitrary media types. Binary response declarations also participate in request Accept negotiation. + - Allow public OAuth PKCE acquisition and refresh when discovery omits `none`, including Keycloak metadata, while retaining confidential-client authentication checks and rejecting malformed authentication-method arrays. diff --git a/docs/migration.md b/docs/migration.md index 7b15db4..efa5827 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -32,3 +32,22 @@ requests, including when the transport borrows a configured client. Leaving `retry_max` unset now selects the cross-runtime dynamic cap of 30 times the current retry interval instead of a fixed 15-second cap. Explicit positive client caps and positive server `retry-max:` controls remain supported. + +## Whole-body binary responses + +Declare whole-body binary results with `ResponseSpec(binary_body=True)`. The transport still checks the declared status +and media types, then supplies the body bytes directly to the result decoder. This preserves JSON and non-UTF-8 text +assets as bytes and accepts empty binary bodies as `b""`. Bodyless 204/205 responses retain their existing semantics. +`OperationSpec` also carries binary response media types into request Accept negotiation; no catch-all codec is needed. + +```python +ResponseSpec( + status=200, + content_types=(MediaType("application/octet-stream"), MediaType("*/*")), + decoder=TypeAdapter(bytes).validate_python, + binary_body=True, +) +``` + +Generated clients must be regenerated with a generator that emits this flag. Older generated clients retain their +existing media-decoding behavior. Encoded byte properties inside JSON models continue to use normal model decoding. diff --git a/src/sunday/httpx/_transport.py b/src/sunday/httpx/_transport.py index ba9cc60..77d1d8e 100644 --- a/src/sunday/httpx/_transport.py +++ b/src/sunday/httpx/_transport.py @@ -164,7 +164,10 @@ def _build_validated_request(self, spec: RequestSpec[Any]) -> httpx.Request: headers["cookie"] = f"{existing_cookie}; {cookie_value}" if existing_cookie else cookie_value if spec.accept_types and "accept" not in headers: supported = tuple( - media_type for media_type in spec.accept_types if self.decoders.find(media_type) is not None + media_type + for media_type in spec.accept_types + if self.decoders.find(media_type) is not None + or any(binary.matches(media_type) for binary in spec.binary_accept_types) ) if not supported: declared = ", ".join(str(media_type) for media_type in spec.accept_types) @@ -408,24 +411,26 @@ def _decode_buffered_response( if not candidates: raise self._unexpected(response, body, "No response specification accepts this status") - if response.status_code in {204, 205} or not body: + response_spec = next((item for item in candidates if item.accepts(content_type)), None) + if response.status_code in {204, 205} or ( + not body and (response_spec is None or not response_spec.binary_body) + ): response_spec = next((item for item in candidates if not item.body_expected), None) if response_spec is None and response.status_code not in {204, 205}: raise self._unexpected(response, body, "Expected a response body") decoded_headers = self._decode_headers(response_spec, headers, response, body) if response_spec else {} return OperationResponse(None, response, response.status_code, headers, decoded_headers) - response_spec = next((item for item in candidates if item.accepts(content_type)), None) if response_spec is None: raise self._unexpected(response, body, "No response specification accepts this media type") if content_type is None: raise self._unexpected(response, body, "A response body was returned without Content-Type") - decoder = self.decoders.find(content_type) - if decoder is None: + decoder = None if response_spec.binary_body else self.decoders.find(content_type) + if decoder is None and not response_spec.binary_body: raise self._unexpected(response, body, "No decoder supports the response media type") try: - decoded = decoder.decode(body, content_type) + decoded = decoder.decode(body, content_type) if decoder is not None else body result = response_spec.decoder(decoded) if response_spec.decoder is not None else decoded except (TypeError, ValueError) as error: raise ResponseDecodingError( diff --git a/src/sunday/specs.py b/src/sunday/specs.py index 3c8d18b..5196673 100644 --- a/src/sunday/specs.py +++ b/src/sunday/specs.py @@ -28,7 +28,11 @@ class RequestPayloadSpec[RequestBodyT]: @dataclass(frozen=True, slots=True) class RequestSpec[RequestBodyT]: - """Declarative HTTP request description consumed by a transport.""" + """Declarative HTTP request description consumed by a transport. + + ``binary_accept_types`` identifies response media types that need no media + decoder. ``OperationSpec`` populates it from binary response declarations. + """ method: str path_template: str | URITemplate @@ -42,6 +46,7 @@ class RequestSpec[RequestBodyT]: template_parameters: Mapping[str, object | None] = field(default_factory=dict) body_adapter: TypeAdapter[Any] | None = None parameter_validation: Callable[[], None] | None = None + binary_accept_types: tuple[MediaType, ...] = () def __post_init__(self) -> None: if self.payload is not None and (self.body is not None or self.content_types): @@ -85,13 +90,19 @@ class ResponseHeaderSpec: @dataclass(frozen=True, slots=True) class ResponseSpec[ResponseT]: - """Expected response status, media types, and generated value decoder.""" + """Expected response status, media types, and generated value decoder. + + ``binary_body`` passes the complete body bytes to the value decoder without + media decoding, including empty bodies. Use it for whole-body binary schemas, + not encoded byte properties within structured responses. + """ status: int | None content_types: tuple[MediaType, ...] = () decoder: Callable[[object], ResponseT] | None = None body_expected: bool = True headers: tuple[ResponseHeaderSpec, ...] = () + binary_body: bool = False def accepts(self, media_type: MediaType | None) -> bool: """Return whether this response specification accepts ``media_type``.""" @@ -102,11 +113,25 @@ def accepts(self, media_type: MediaType | None) -> bool: @dataclass(frozen=True, slots=True) class OperationSpec[RequestBodyT, ResponseT]: - """A generated request and the possible successful responses.""" + """A generated request and the possible successful responses. + + Binary response media types are carried into request negotiation so they do + not require a registered media decoder. + """ request: RequestSpec[RequestBodyT] responses: tuple[ResponseSpec[ResponseT], ...] + def __post_init__(self) -> None: + binary_types = tuple( + media + for response in self.responses + if response.binary_body + for media in response.content_types or (MediaType("*/*"),) + ) + if self.request.binary_accept_types != binary_types: + object.__setattr__(self, "request", replace(self.request, binary_accept_types=binary_types)) + @dataclass(frozen=True, slots=True) class NullifySpec: diff --git a/tests/test_httpx_binary.py b/tests/test_httpx_binary.py new file mode 100644 index 0000000..e06bb17 --- /dev/null +++ b/tests/test_httpx_binary.py @@ -0,0 +1,186 @@ +# Copyright 2026 Outfox, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +from __future__ import annotations + +import httpx +import pytest +from pydantic import Base64Bytes, TypeAdapter + +from sunday import ( + MediaType, + Operation, + OperationSpec, + Problem, + RequestSpec, + ResponseHeaderSpec, + ResponseSpec, + SundayModel, + UnexpectedResponse, +) +from sunday.httpx import HttpxTransport + + +@pytest.mark.anyio +@pytest.mark.parametrize("wildcard", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +@pytest.mark.parametrize( + ("media_type", "body"), + [ + ("application/octet-stream", b"\x00\xff"), + ("image/png", b"\x89PNG\r\n\x1a\n"), + ("application/pdf", b"%PDF-1.7\n\x00\xff"), + ("audio/mpeg", b"\x00\xff"), + ("video/mp4", b"\x00\xff"), + ("application/vnd.example.asset", b"\x00\xff"), + ("application/json", b'{ "asset": true }\n'), + ("text/plain; charset=iso-8859-1", b"caf\xe9"), + ], +) +async def test_binary_response_preserves_bytes_and_metadata( + media_type: str, body: bytes, empty: bool, wildcard: bool +) -> None: + if empty: + body = b"" + content_types = (MediaType("application/octet-stream"), MediaType("*/*")) if wildcard else (MediaType(media_type),) + + def handler(_request: httpx.Request) -> httpx.Response: + assert _request.headers["Accept"] == ", ".join(map(str, content_types)) + return httpx.Response(200, content=body, headers={"Content-Type": media_type, "X-Count": "42"}) + + async with httpx.AsyncClient(base_url="https://example.test", transport=httpx.MockTransport(handler)) as client: + operation: Operation[bytes, httpx.Request, httpx.Response] = Operation( + HttpxTransport(client), + OperationSpec( + RequestSpec("GET", "/asset", accept_types=content_types), + ( + ResponseSpec( + 200, + content_types, + TypeAdapter(bytes).validate_python, + headers=(ResponseHeaderSpec("X-Count", int, required=True),), + binary_body=True, + ), + ), + ), + ) + result = await operation.execute() + assert type(result) is bytes and result == body + response = await operation.response() + assert type(response.result) is bytes and response.result == body + assert response.status == 200 + assert response.content_type == MediaType(media_type) + assert response.get_header("X-Count") == "42" + assert response.decoded_header("X-Count") == 42 + assert response.transport_response.content == body + assert response.transport_response.is_closed + + +@pytest.mark.anyio +@pytest.mark.parametrize("body", [b"", b"\x00\xff"]) +@pytest.mark.parametrize( + ("status", "media_type", "message"), + [ + (201, "image/png", "status"), + (200, "application/json", "media type|Expected a response body"), + (200, None, "media type|Expected a response body"), + ], +) +async def test_binary_response_enforces_contract( + status: int, media_type: str | None, message: str, body: bytes +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(status, content=body, headers={"Content-Type": media_type} if media_type else {}) + + async with httpx.AsyncClient(base_url="https://example.test", transport=httpx.MockTransport(handler)) as client: + operation: Operation[object, httpx.Request, httpx.Response] = Operation( + HttpxTransport(client), + OperationSpec( + RequestSpec("GET", "/asset"), + (ResponseSpec(200, (MediaType("image/png"),), binary_body=True),), + ), + ) + for call in (operation.execute, operation.response): + with pytest.raises(UnexpectedResponse, match=message): + await call() + + +@pytest.mark.anyio +async def test_binary_response_keeps_problem_and_structured_model_decoding() -> None: + class Asset(SundayModel): + data: Base64Bytes + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/error": + return httpx.Response( + 404, json={"status": 404, "title": "Missing"}, headers={"Content-Type": "application/problem+json"} + ) + return httpx.Response(200, json={"data": "Y2Fm6Q=="}) + + async with httpx.AsyncClient(base_url="https://example.test", transport=httpx.MockTransport(handler)) as client: + transport = HttpxTransport(client) + binary: Operation[bytes, httpx.Request, httpx.Response] = Operation( + transport, + OperationSpec( + RequestSpec("GET", "/error"), + (ResponseSpec(None, (MediaType("*/*"),), TypeAdapter(bytes).validate_python, binary_body=True),), + ), + ) + for call in (binary.execute, binary.response): + with pytest.raises(Problem) as raised: + await call() + assert raised.value.status == 404 + structured: Operation[Asset, httpx.Request, httpx.Response] = Operation( + transport, + OperationSpec( + RequestSpec("GET", "/model"), + (ResponseSpec(200, (MediaType("application/json"),), TypeAdapter(Asset).validate_python),), + ), + ) + assert (await structured.execute()).data == b"caf\xe9" + + +@pytest.mark.anyio +async def test_binary_selection_is_response_specific_and_preserves_adapters() -> None: + calls = 0 + + async def adapt(_transport: HttpxTransport, request: httpx.Request) -> httpx.Request: + request.headers["Authorization"] = "Bearer test-token" + return request + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + assert request.headers["Authorization"] == "Bearer test-token" + assert request.headers["Accept"] == "image/png, application/json" + calls += 1 + if calls % 2: + return httpx.Response(200, content=b"\x00\xff", headers={"Content-Type": "image/png"}) + return httpx.Response(201, json={"value": 42}) + + async with httpx.AsyncClient(base_url="https://example.test", transport=httpx.MockTransport(handler)) as client: + operation: Operation[object, httpx.Request, httpx.Response] = Operation( + HttpxTransport(client, adapters=(adapt,)), + OperationSpec( + RequestSpec("GET", "/asset", accept_types=(MediaType("image/png"), MediaType("application/json"))), + ( + ResponseSpec(200, (MediaType("image/png"),), binary_body=True), + ResponseSpec(201, (MediaType("application/json"),)), + ), + ), + ) + for _ in range(2): + assert await operation.execute() == b"\x00\xff" + assert (await operation.response()).result == {"value": 42} + + +def test_operation_updates_binary_negotiation_when_responses_are_replaced() -> None: + from dataclasses import replace + + request: RequestSpec[None] = RequestSpec("GET", "/asset", accept_types=(MediaType("application/pdf"),)) + operation: OperationSpec[None, object] = OperationSpec(request, (ResponseSpec(200, binary_body=True),)) + assert operation.request.binary_accept_types == (MediaType("*/*"),) + assert request.binary_accept_types == () + structured = replace(operation, responses=(ResponseSpec(200),)) + assert structured.request.binary_accept_types == () From af4424116631ec32f059c5bd935fb83f079a551a Mon Sep 17 00:00:00 2001 From: Kevin Wooten Date: Thu, 15 Oct 2026 17:13:57 -0700 Subject: [PATCH 2/2] Accept overlapping binary response media ranges Check Accept and binary response declarations in both matching directions. Cover broader request and response ranges, structured suffix wildcards, and rejection of disjoint ranges through both operation APIs. --- src/sunday/httpx/_transport.py | 2 +- tests/test_httpx_binary.py | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/sunday/httpx/_transport.py b/src/sunday/httpx/_transport.py index 77d1d8e..bd8f122 100644 --- a/src/sunday/httpx/_transport.py +++ b/src/sunday/httpx/_transport.py @@ -167,7 +167,7 @@ def _build_validated_request(self, spec: RequestSpec[Any]) -> httpx.Request: media_type for media_type in spec.accept_types if self.decoders.find(media_type) is not None - or any(binary.matches(media_type) for binary in spec.binary_accept_types) + or any(binary.matches(media_type) or media_type.matches(binary) for binary in spec.binary_accept_types) ) if not supported: declared = ", ".join(str(media_type) for media_type in spec.accept_types) diff --git a/tests/test_httpx_binary.py b/tests/test_httpx_binary.py index e06bb17..edd695b 100644 --- a/tests/test_httpx_binary.py +++ b/tests/test_httpx_binary.py @@ -11,9 +11,11 @@ from sunday import ( MediaType, + MediaTypeDecoders, Operation, OperationSpec, Problem, + RequestEncodingError, RequestSpec, ResponseHeaderSpec, ResponseSpec, @@ -184,3 +186,52 @@ def test_operation_updates_binary_negotiation_when_responses_are_replaced() -> N assert request.binary_accept_types == () structured = replace(operation, responses=(ResponseSpec(200),)) assert structured.request.binary_accept_types == () + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("accept", "declared", "actual", "overlaps"), + [ + ("image/*", "image/png", "image/png", True), + ("*/*", "image/png", "image/png", True), + ("image/png", "image/*", "image/png", True), + ("image/png", "*/*", "image/png", True), + ("image/*", "image/*", "image/png", True), + ("application/*+json", "application/vnd.asset+json", "application/vnd.asset+json", True), + ("application/vnd.asset+json", "application/*+json", "application/vnd.asset+json", True), + ("application/*", "application/*+json", "application/vnd.asset+json", True), + ("application/*+json", "application/*", "application/vnd.asset+json", True), + ("image/*", "audio/mpeg", "audio/mpeg", False), + ("image/png", "image/jpeg", "image/jpeg", False), + ("application/*+json", "application/*+xml", "application/vnd.asset+xml", False), + ], +) +async def test_binary_accept_negotiation_matches_overlapping_ranges( + accept: str, declared: str, actual: str, overlaps: bool +) -> None: + requests = 0 + body = b"\x00\xff" + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal requests + requests += 1 + assert request.headers["Accept"] == accept + return httpx.Response(200, content=body, headers={"Content-Type": actual}) + + async with httpx.AsyncClient(base_url="https://example.test", transport=httpx.MockTransport(handler)) as client: + operation: Operation[bytes, httpx.Request, httpx.Response] = Operation( + HttpxTransport(client, decoders=MediaTypeDecoders()), + OperationSpec( + RequestSpec("GET", "/asset", accept_types=(MediaType(accept),)), + (ResponseSpec(200, (MediaType(declared),), TypeAdapter(bytes).validate_python, binary_body=True),), + ), + ) + if overlaps: + assert await operation.execute() == body + assert (await operation.response()).result == body + assert requests == 2 + else: + for call in (operation.execute, operation.response): + with pytest.raises(RequestEncodingError, match="No response decoder"): + await call() + assert requests == 0