feat(api): support typed binary success response bodies

Preserve byte-exact BinaryBody responses in generated Axum/Reqwest adapters, declared response headers, metadata and inline binary OpenAPI schemas. Request and JSON/error behavior stay unchanged. Validated all-feature api-macros/api-macros-impl suites including 5 binary response tests, existing HTTP adapters, OpenAPI and compile-fail contracts.
This commit is contained in:
2026-10-09 00:02:09 +09:00
parent 60ec815237
commit 9da79ab9f0
6 changed files with 403 additions and 7 deletions
+32 -4
View File
@@ -1534,6 +1534,13 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> {
Ok(())
}
fn is_binary_body_type(ty: &Type) -> bool {
matches!(ty, Type::Path(path)
if path.qself.is_none()
&& path.path.segments.last().is_some_and(|last|
last.ident == "BinaryBody" && matches!(last.arguments, PathArguments::None)))
}
fn validate_binary_body_type(ty: &Type) -> syn::Result<()> {
let Type::Path(path) = ty else {
return Err(syn::Error::new_spanned(
@@ -1738,6 +1745,9 @@ fn reqwest_adapter_tokens(
}
});
let body_parser = match response.body.as_ref() {
Some(ty) if is_binary_body_type(ty) => quote! {
let body = __response.decode_binary();
},
Some(ty) => quote! {
let body = __response
.decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success)
@@ -1775,7 +1785,11 @@ fn reqwest_adapter_tokens(
.alternate_status
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
.unwrap_or_else(|| quote!(__actual == #response_status));
let success = if let Some(response) = operation.response_body.as_ref() {
let success = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
quote! {
return ::core::result::Result::Ok(__response.decode_binary());
}
} else if let Some(response) = operation.response_body.as_ref() {
quote! {
return __response
.decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success)
@@ -2133,7 +2147,9 @@ fn axum_adapter_tokens(
let variant_ident = format_ident!("Status{}", status);
let body_pattern = response.body.as_ref().map(|_| quote!(body,));
let header_fields = response.headers.iter().map(|header| &header.field_ident);
let base_response = if response.body.is_some() {
let base_response = if response.body.as_ref().is_some_and(is_binary_body_type) {
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#status), body))
} else if response.body.is_some() {
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body))
} else {
quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status)))
@@ -2170,14 +2186,21 @@ fn axum_adapter_tokens(
}
}
} else if let Some(alternate_status) = operation.alternate_status {
let response_encoder = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
quote!(#api_crate::axum::binary_response)
} else {
quote!(#api_crate::axum::json_response)
};
quote!({
let status = #api_crate::HttpSuccess::status_code(&value);
if status == #response_status || status == #alternate_status {
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
#response_encoder(#api_crate::axum::status(status), value)
} else {
#api_crate::axum::empty_response(#api_crate::axum::status(500))
}
})
} else if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#response_status), value))
} else if operation.response_body.is_some() {
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
} else {
@@ -2323,6 +2346,9 @@ fn openapi_adapter_tokens(
"Alternate successful response"
};
let body = match response.body.as_ref() {
Some(ty) if is_binary_body_type(ty) => quote! {
operation.binary_response(#status, #description)?;
},
Some(ty) => quote! {
operation.response::<#ty>(#status, "application/json", #description)?;
},
@@ -2620,7 +2646,9 @@ fn metadata_tokens(
};
let success_responses = operation.success_responses.iter().map(|response| {
let status = response.status;
let wire_kind = if response.body.is_some() {
let wire_kind = if response.body.as_ref().is_some_and(is_binary_body_type) {
quote!(#api_crate::WireKind::Binary)
} else if response.body.is_some() {
quote!(#api_crate::WireKind::Json)
} else {
quote!(#api_crate::WireKind::Empty)
+10
View File
@@ -77,6 +77,16 @@ pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
(status, Json(value)).into_response()
}
/// Emit an exact binary response with the contract status and octet-stream content type.
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
(
status,
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
value.into_bytes(),
)
.into_response()
}
/// Emit an empty response with the contract status.
pub fn empty_response(status: StatusCode) -> Response {
status.into_response()
+6 -3
View File
@@ -56,6 +56,9 @@
//! `#[header("x-request-id")]`. Exactly one JSON or binary body is allowed. JSON request, response,
//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous
//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact.
//! A named success response whose final type identifier is `BinaryBody` is likewise byte-exact,
//! uses `application/octet-stream`, and may include declared response headers. Generated clients
//! collect binary responses under the same configured response byte limit as JSON responses.
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
//!
@@ -96,7 +99,7 @@
//! deterministic `Operation<utf8-hex>` fallback. The `ApiContract::OPERATIONS` inventory is
//! sorted by operation ID, so its ordering is independent of source method order.
//!
//! Empty, JSON, and bounded binary request bodies are supported. [`WireKind`] reserves explicit
//! Empty, JSON, and bounded binary request and success response bodies are supported. [`WireKind`] reserves explicit
//! variants for other future transport work; accepting one requires a deliberate macro and adapter
//! change rather than silently treating it as JSON.
//!
@@ -148,7 +151,7 @@ pub mod openapi;
#[cfg(feature = "reqwest")]
pub mod reqwest;
/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies.
/// Framework-neutral owned bytes used by explicit `#[binary]` requests and binary success responses.
///
/// The wrapper preserves the exact byte sequence while keeping payload content out of `Debug`
/// output. Its `bytes::Bytes` storage lets generated Axum adapters transfer an extracted body
@@ -314,7 +317,7 @@ pub trait Operation {
type Parameters;
/// Typed JSON or binary request body, or [`NoBody`].
type RequestBody;
/// JSON success response body type, generated declared-response result, or [`NoBody`].
/// JSON or binary success body type, generated declared-response result, or [`NoBody`].
type ResponseBody;
/// JSON public error body type, or [`NoBody`].
type ErrorBody;
+19
View File
@@ -419,6 +419,25 @@ impl OpenApiOperation<'_> {
)
}
/// Describe an octet-stream response inline, without registering a JSON schema component.
pub fn binary_response(
&mut self,
status: u16,
description: &'static str,
) -> Result<(), OpenApiError> {
self.insert_response(
status,
json!({
"description": description,
"content": {
"application/octet-stream": {
"schema": { "type": "string", "format": "binary" }
}
},
}),
)
}
pub fn empty_response(
&mut self,
status: u16,
+5
View File
@@ -516,6 +516,11 @@ impl ReceivedResponse {
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
}
/// Move the exact response bytes, already collected under the configured limit, into a body.
pub fn decode_binary(self) -> crate::BinaryBody {
self.bytes.into()
}
pub fn require_empty(&self) -> Result<(), ClientFailure> {
if self.bytes.is_empty() {
Ok(())
+331
View File
@@ -0,0 +1,331 @@
#![cfg(all(feature = "reqwest", feature = "axum"))]
use api_macros::{
BinaryBody, Operation, api,
axum::framework::{Body, Response, Router, get},
reqwest::{ClientError, ClientFailure},
};
#[cfg(feature = "openapi")]
use api_macros::{
WireKind,
openapi::{OpenApiInfo, OpenApiSchema},
};
#[cfg(feature = "openapi")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[cfg(feature = "openapi")]
use serde_json::json;
const BYTES: &[u8] = b"\0\xff\x80\r\n{\"not\":\"a JSON response\"}";
const ETAG: &str = "\"binary-v1\"";
const DISPOSITION: &str = "attachment; filename=fixture.bin";
#[derive(Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
pub struct PublicError {
message: String,
}
#[cfg(feature = "openapi")]
impl OpenApiSchema for PublicError {}
impl api_macros::HttpError for PublicError {
fn status_code(&self) -> u16 {
404
}
}
#[cfg_attr(feature = "openapi", api(reqwest, axum, openapi))]
#[cfg_attr(not(feature = "openapi"), api(reqwest, axum))]
pub trait DownloadApi {
#[get("/plain/{mode}", error_status = 404)]
async fn plain(&self, mode: String) -> Result<api_macros::BinaryBody, PublicError>;
#[get(
"/download/{mode}",
error_status = 404,
responses = [
(status = 200, body = BinaryBody, headers = [
("x-byte-length", u32), ("content-disposition", String), ("etag", String)
]),
(status = 304, headers = [("etag", String)])
]
)]
async fn download(&self, mode: String)
-> Result<download_api_responses::Download, PublicError>;
}
struct DownloadService;
impl DownloadApi for DownloadService {
async fn plain(&self, mode: String) -> Result<BinaryBody, PublicError> {
if mode == "missing" {
return Err(PublicError {
message: "not found".to_owned(),
});
}
Ok(if mode == "empty" {
b"".as_slice()
} else {
BYTES
}
.into())
}
async fn download(
&self,
mode: String,
) -> Result<download_api_responses::Download, PublicError> {
if mode == "cached" {
return Ok(download_api_responses::Download::Status304 {
header_etag: ETAG.to_owned(),
});
}
let body = self.plain(mode).await?;
Ok(download_api_responses::Download::Status200 {
header_x_byte_length: body.len() as u32,
header_content_disposition: DISPOSITION.to_owned(),
header_etag: ETAG.to_owned(),
body,
})
}
}
struct Server {
base_url: String,
task: tokio::task::JoinHandle<()>,
}
impl Server {
async fn start(router: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}/", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
api_macros::axum::framework::serve(listener, router)
.await
.unwrap();
});
Self { base_url, task }
}
fn client(&self, limit: usize) -> DownloadApiClient {
DownloadApiClient::builder(&self.base_url)
.unwrap()
.response_body_limit(limit)
.build()
.unwrap()
}
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
#[tokio::test]
async fn binary_success_preserves_exact_bytes_content_type_and_declared_headers() {
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
let client = server.client(BYTES.len());
for (mode, expected) in [("full", BYTES), ("empty", b"".as_slice())] {
let response = reqwest::get(format!("{}plain/{mode}", server.base_url))
.await
.unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(
response.headers()["content-type"],
"application/octet-stream"
);
assert_eq!(response.bytes().await.unwrap().as_ref(), expected);
assert_eq!(
client.plain(mode.to_owned()).await.unwrap().as_ref(),
expected
);
let response = client.download(mode.to_owned()).await.unwrap();
match response {
download_api_responses::Download::Status200 {
body,
header_x_byte_length,
header_content_disposition,
header_etag,
} => {
assert_eq!(body.as_ref(), expected);
assert_eq!(header_x_byte_length as usize, expected.len());
assert_eq!(header_content_disposition, DISPOSITION);
assert_eq!(header_etag, ETAG);
}
other => panic!("expected binary success, received {other:?}"),
}
}
assert!(
matches!(client.download("cached".to_owned()).await.unwrap(),
download_api_responses::Download::Status304 { header_etag } if header_etag == ETAG)
);
}
#[cfg(feature = "openapi")]
#[test]
fn binary_success_metadata_and_openapi_use_inline_binary_schema_with_headers() {
let document = download_api_openapi(OpenApiInfo {
title: "Download fixture",
version: "1",
source_digest: "fixture",
})
.unwrap();
let value = document.as_value();
for (path, metadata) in [
("/plain/{mode}", download_api_operations::Plain::METADATA),
(
"/download/{mode}",
download_api_operations::Download::METADATA,
),
] {
assert_eq!(
metadata.success_responses[0].body.wire_kind,
WireKind::Binary
);
assert_eq!(metadata.error_responses[0].body.wire_kind, WireKind::Json);
let responses = &value["paths"][path]["get"]["responses"];
assert_eq!(
responses["200"]["content"],
json!({
"application/octet-stream": { "schema": { "type": "string", "format": "binary" } }
})
);
assert_eq!(
responses["404"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/PublicError"
);
}
let metadata = download_api_operations::Download::METADATA;
assert_eq!(
metadata.success_responses[1].body.wire_kind,
WireKind::Empty
);
let responses = &value["paths"]["/download/{mode}"]["get"]["responses"];
for header in metadata.success_responses[0].headers {
assert!(responses["200"]["headers"][header.wire_name]["schema"].is_object());
}
assert!(responses["304"].get("content").is_none());
assert!(responses["304"]["headers"]["etag"]["schema"].is_object());
assert!(
value["components"]["schemas"]
.as_object()
.unwrap()
.keys()
.all(|name| !name.contains("BinaryBody"))
);
}
#[tokio::test]
async fn binary_success_keeps_json_public_errors_and_rejects_unexpected_statuses() {
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
let client = server.client(1024);
for result in [
client.plain("missing".to_owned()).await.map(|_| ()),
client.download("missing".to_owned()).await.map(|_| ()),
] {
match result.unwrap_err() {
ClientError::Public { status, error } => {
assert_eq!(status.as_u16(), 404);
assert_eq!(error.message, "not found");
}
other => panic!("expected JSON public error, received {other:?}"),
}
}
for (status, expected) in [
(
201,
ClientFailure::UnexpectedStatus {
expected: 200,
actual: 201,
},
),
(404, ClientFailure::ErrorResponseDecode { status: 404 }),
] {
let server = Server::start(Router::new().route(
download_api_operations::Plain::METADATA.path,
get(move || async move {
Response::builder()
.status(status)
.body(Body::from(BYTES))
.unwrap()
}),
))
.await;
assert!(matches!(server.client(1024).plain("full".to_owned()).await,
Err(ClientError::Failure(actual)) if actual == expected));
}
}
#[tokio::test]
async fn binary_declared_headers_reject_missing_invalid_and_duplicate_values() {
for (values, expected) in [
(
vec![],
ClientFailure::MissingResponseHeader { name: "etag" },
),
(
vec![b"\xff".as_slice()],
ClientFailure::InvalidResponseHeader { name: "etag" },
),
(
vec![b"one".as_slice(), b"two".as_slice()],
ClientFailure::DuplicateResponseHeader { name: "etag" },
),
] {
let server = Server::start(Router::new().route(
download_api_operations::Download::METADATA.path,
get(move || {
let values = values.clone();
async move {
let mut response = Response::builder()
.status(200)
.header("content-type", "application/octet-stream")
.header("x-byte-length", BYTES.len())
.header("content-disposition", DISPOSITION);
for value in values {
response = response.header("etag", value);
}
response.body(Body::from(BYTES)).unwrap()
}
}),
))
.await;
assert!(
matches!(server.client(1024).download("full".to_owned()).await,
Err(ClientError::Failure(actual)) if actual == expected)
);
}
}
#[tokio::test]
async fn binary_responses_enforce_the_same_limit_with_and_without_content_length() {
for chunked in [false, true] {
let server = Server::start(Router::new().route(
download_api_operations::Plain::METADATA.path,
get(move || async move {
if chunked {
let chunks = [BYTES[..3].to_vec(), BYTES[3..].to_vec()];
let stream = futures::stream::iter(chunks.map(Ok::<_, std::io::Error>));
Response::builder()
.status(200)
.body(Body::from_stream(stream))
.unwrap()
} else {
api_macros::axum::binary_response(api_macros::axum::status(200), BYTES.into())
}
}),
))
.await;
assert_eq!(
server
.client(BYTES.len())
.plain("full".to_owned())
.await
.unwrap()
.as_ref(),
BYTES
);
assert!(
matches!(server.client(BYTES.len() - 1).plain("full".to_owned()).await,
Err(ClientError::Failure(ClientFailure::ResponseTooLarge { limit })) if limit == BYTES.len() - 1)
);
}
}