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..bd8f122 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) 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) @@ -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..edd695b --- /dev/null +++ b/tests/test_httpx_binary.py @@ -0,0 +1,237 @@ +# 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, + MediaTypeDecoders, + Operation, + OperationSpec, + Problem, + RequestEncodingError, + 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 == () + + +@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