diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 8014ff556..c3fb7af7e 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -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) diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index 8f8807277..5993963a6 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -77,6 +77,16 @@ pub fn json_response(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() diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index a3227f814..5d21d49e2 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -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` 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; diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 81b6a096a..9fc172d09 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -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, diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs index 2a93c984a..9c8cf2664 100644 --- a/crates/api-macros/src/reqwest.rs +++ b/crates/api-macros/src/reqwest.rs @@ -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(()) diff --git a/crates/api-macros/tests/binary_responses.rs b/crates/api-macros/tests/binary_responses.rs new file mode 100644 index 000000000..07cbb20af --- /dev/null +++ b/crates/api-macros/tests/binary_responses.rs @@ -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; + + #[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; +} + +struct DownloadService; +impl DownloadApi for DownloadService { + async fn plain(&self, mode: String) -> Result { + 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 { + 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) + ); + } +}