Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 11 additions & 6 deletions src/sunday/httpx/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 28 additions & 3 deletions src/sunday/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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``."""
Expand All @@ -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:
Expand Down
237 changes: 237 additions & 0 deletions tests/test_httpx_binary.py
Original file line number Diff line number Diff line change
@@ -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
Loading