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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user