diff --git a/Cargo.lock b/Cargo.lock index cc29a095e..572137e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,6 +174,7 @@ version = "0.1.0" dependencies = [ "api-macros-impl", "axum", + "bytes", "futures", "oas3", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 9c284b62e..ff5061333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,6 +118,7 @@ yoi-workspace-server = { path = "crates/workspace-server" } async-trait = "0.1" axum = "0.8" base64 = "0.22.1" +bytes = "1" decodal = "0.4.0" decodal-language-service = "0.4.0" decodal-language-tools = "0.4.0" diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 2395767ef..5cfbc1d4e 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -225,6 +225,7 @@ enum Location { Query, Header, Body, + Binary, /// Trusted request-local context supplied by the server adapter. Extension /// parameters are not part of the wire contract or generated client signature. Extension, @@ -236,7 +237,7 @@ impl Location { Self::Path => quote!(Path), Self::Query => quote!(Query), Self::Header => quote!(Header), - Self::Body => quote!(Body), + Self::Body | Self::Binary => quote!(Body), Self::Extension => unreachable!("extensions are not wire parameters"), }; quote!(#api_crate::ParameterLocation::#variant) @@ -251,6 +252,17 @@ struct Parameter { ty: Type, } +#[derive(Clone, Copy)] +enum RequestWireKind { + Json, + Binary, +} + +struct RequestBody { + ty: Type, + wire_kind: RequestWireKind, +} + struct Operation { method_ident: Ident, marker_ident: Ident, @@ -258,7 +270,7 @@ struct Operation { method: Method, path: String, parameters: Vec, - request_body: Option, + request_body: Option, response_body: Option, error_body: Option, fallible: bool, @@ -575,6 +587,15 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { } (Location::Body, rust_name.clone()) } + Some((Location::Binary, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[binary] does not accept a wire name", + )); + } + (Location::Binary, rust_name.clone()) + } Some((Location::Extension, wire_name)) => { if wire_name.is_some() { return Err(syn::Error::new( @@ -588,7 +609,7 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { None => { return Err(syn::Error::new( ident.span(), - "API arguments must use #[path], #[query], #[header], #[body], or #[extension]; path arguments may omit #[path] when their name matches a placeholder", + "API arguments must use #[path], #[query], #[header], #[body], #[binary], or #[extension]; path arguments may omit #[path] when their name matches a placeholder", )); } }; @@ -596,15 +617,28 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { if matches!(location, Location::Path) { path_parameters.insert(rust_name.clone()); } - if matches!(location, Location::Body) { + if matches!(location, Location::Body | Location::Binary) { if body_type.is_some() { return Err(syn::Error::new( ident.span(), - "an API operation may declare only one request body", + "an API operation may declare only one JSON or binary request body", )); } - validate_named_body_type(&input.ty, "request")?; - body_type = Some((*input.ty).clone()); + let wire_kind = match location { + Location::Body => { + validate_named_body_type(&input.ty, "request")?; + RequestWireKind::Json + } + Location::Binary => { + validate_binary_body_type(&input.ty)?; + RequestWireKind::Binary + } + _ => unreachable!(), + }; + body_type = Some(RequestBody { + ty: (*input.ty).clone(), + wire_kind, + }); } parameters.push(Parameter { rust_ident: ident.clone(), @@ -644,6 +678,20 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { ), )); } + if route + .normalize_body_errors + .as_ref() + .is_some_and(|value| value.value) + && (body_type.is_none() || error_body.is_none()) + { + return Err(syn::Error::new_spanned( + route + .normalize_body_errors + .as_ref() + .expect("checked normalize_body_errors"), + "normalize_body_errors requires a request body and public error response type", + )); + } Ok(Operation { marker_ident: operation_marker_ident(&method.sig.ident), @@ -717,8 +765,9 @@ fn take_location(attrs: &mut Vec) -> syn::Result Some(Location::Query), Some("header") => Some(Location::Header), Some("body") => Some(Location::Body), + Some("binary") => Some(Location::Binary), Some("extension") => Some(Location::Extension), - Some("stream") | Some("multipart") | Some("binary") => { + Some("stream") | Some("multipart") => { return Err(syn::Error::new_spanned( attr, "this wire kind is explicitly unsupported by the initial API contract", @@ -930,6 +979,31 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { Ok(()) } +fn validate_binary_body_type(ty: &Type) -> syn::Result<()> { + let Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + }; + let Some(last) = path.path.segments.last() else { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + }; + if path.qself.is_some() + || last.ident != "BinaryBody" + || !matches!(last.arguments, PathArguments::None) + { + return Err(syn::Error::new_spanned( + ty, + "binary request bodies must use api_macros::BinaryBody", + )); + } + Ok(()) +} + fn argument_ident(pat: &Pat) -> syn::Result<&Ident> { let Pat::Ident(ident) = pat else { return Err(syn::Error::new_spanned( @@ -1048,14 +1122,20 @@ fn reqwest_adapter_tokens( let body = operation .parameters .iter() - .find(|parameter| matches!(parameter.location, Location::Body)) + .find(|parameter| matches!(parameter.location, Location::Body | Location::Binary)) .map(|parameter| { let ident = ¶meter.rust_ident; - quote! { - ::core::option::Option::Some( - #api_crate::reqwest::encode_json(&#ident) - .map_err(#api_crate::reqwest::ClientError::from)?, - ) + match parameter.location { + Location::Body => quote! { + ::core::option::Option::Some( + #api_crate::reqwest::encode_json(&#ident) + .map_err(#api_crate::reqwest::ClientError::from)?, + ) + }, + Location::Binary => quote! { + ::core::option::Option::Some(#api_crate::reqwest::encode_binary(#ident)) + }, + _ => unreachable!(), } }) .unwrap_or_else(|| quote!(::core::option::Option::None)); @@ -1329,44 +1409,81 @@ fn axum_adapter_tokens( let (body_extractor, body_rejection) = operation .parameters .iter() - .find(|parameter| matches!(parameter.location, Location::Body)) + .find(|parameter| matches!(parameter.location, Location::Body | Location::Binary)) .map(|parameter| { let ident = ¶meter.rust_ident; let ty = ¶meter.ty; - if !operation.normalize_body_errors { - return ( + match parameter.location { + Location::Body if !operation.normalize_body_errors => ( Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)), None, - ); + ), + Location::Body => { + let error = operation + .error_body + .as_ref() + .expect("normalized body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Json<#ty>, + #api_crate::axum::framework::JsonRejection, + >, + )), + Some(quote! { + let #ident = match __body { + ::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value, + ::core::result::Result::Err(rejection) => { + let status = rejection.status().as_u16(); + let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( + status, + rejection.body_text(), + ); + return #api_crate::axum::json_response( + #api_crate::axum::status(status), + error, + ); + } + }; + }), + ) + } + Location::Binary if !operation.normalize_body_errors => ( + Some(quote!(#api_crate::axum::framework::Bytes(__body): #api_crate::axum::framework::Bytes,)), + Some(quote!(let #ident: #ty = __body.into();)), + ), + Location::Binary => { + let error = operation + .error_body + .as_ref() + .expect("normalized body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Bytes, + #api_crate::axum::framework::BytesRejection, + >, + )), + Some(quote! { + let #ident: #ty = match __body { + ::core::result::Result::Ok(value) => value.into(), + ::core::result::Result::Err(rejection) => { + let status = rejection.status().as_u16(); + let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( + status, + rejection.body_text(), + ); + return #api_crate::axum::json_response( + #api_crate::axum::status(status), + error, + ); + } + }; + }), + ) + } + _ => unreachable!(), } - let error = operation - .error_body - .as_ref() - .expect("body operations require an error type"); - ( - Some(quote!( - __body: ::core::result::Result< - #api_crate::axum::framework::Json<#ty>, - #api_crate::axum::framework::JsonRejection, - >, - )), - Some(quote! { - let #ident = match __body { - ::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value, - ::core::result::Result::Err(rejection) => { - let status = rejection.status().as_u16(); - let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( - status, - rejection.body_text(), - ); - return #api_crate::axum::json_response( - #api_crate::axum::status(status), - error, - ); - } - }; - }), - ) }) .unwrap_or((None, None)); let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); @@ -1485,7 +1602,7 @@ fn openapi_adapter_tokens( let path = &operation.path; let operation_id = &operation.operation_id; let parameters = operation.parameters.iter().filter_map(|parameter| { - if matches!(parameter.location, Location::Body | Location::Extension) { + if matches!(parameter.location, Location::Body | Location::Binary | Location::Extension) { return None; } let ty = ¶meter.ty; @@ -1494,16 +1611,22 @@ fn openapi_adapter_tokens( Location::Path => "path", Location::Query => "query", Location::Header => "header", - Location::Body | Location::Extension => unreachable!(), + Location::Body | Location::Binary | Location::Extension => unreachable!(), }; Some(quote! { operation.parameter::<#ty>(#name, #location)?; }) }); - let request = operation.request_body.as_ref().map(|ty| { - quote! { - operation.request_body::<#ty>("application/json")?; + let request = operation.request_body.as_ref().map(|body| match body.wire_kind { + RequestWireKind::Json => { + let ty = &body.ty; + quote! { + operation.request_body::<#ty>("application/json")?; + } } + RequestWireKind::Binary => quote! { + operation.binary_request_body()?; + }, }); let status = operation.response_status; let response = match &operation.response_body { @@ -1623,7 +1746,10 @@ fn expand_api(api: ApiDefinition) -> syn::Result { let request_type = operation .request_body .as_ref() - .map(|ty| quote!(#ty)) + .map(|body| { + let ty = &body.ty; + quote!(#ty) + }) .unwrap_or_else(|| quote!(#api_crate::NoBody)); let response_type = operation .response_body @@ -1686,10 +1812,10 @@ fn metadata_tokens( let operation_id = &operation.operation_id; let method = operation.method.tokens(api_crate); let path = &operation.path; - let request_kind = if operation.request_body.is_some() { - quote!(#api_crate::WireKind::Json) - } else { - quote!(#api_crate::WireKind::Empty) + let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) { + Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json), + Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary), + None => quote!(#api_crate::WireKind::Empty), }; let response_kind = if operation.response_body.is_some() { quote!(#api_crate::WireKind::Json) diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml index 0bc19b435..a99fb7bde 100644 --- a/crates/api-macros/Cargo.toml +++ b/crates/api-macros/Cargo.toml @@ -20,6 +20,7 @@ reqwest = [ [dependencies] api-macros-impl = { path = "../api-macros-impl" } +bytes.workspace = true axum = { workspace = true, optional = true } futures = { workspace = true, optional = true } reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] } diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index aeaa3bfc7..799a072da 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -52,7 +52,11 @@ pub fn status(code: u16) -> StatusCode { pub mod framework { pub use axum::{ Json, Router, - extract::{Extension, Path, Query, State, rejection::JsonRejection}, + body::Bytes, + extract::{ + DefaultBodyLimit, Extension, Path, Query, State, + rejection::{BytesRejection, JsonRejection}, + }, http::{HeaderMap, StatusCode}, response::Response, routing::{delete, get, head, options, patch, post, put}, diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 318bad6ef..45f4ec51c 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -45,12 +45,13 @@ //! security schemes. Body operations may opt into typed Axum rejection normalization with //! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`]. //! -//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`. -//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them. -//! An unannotated argument whose Rust name occurs in the route template is inferred as a path -//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`. -//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named -//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected. +//! Arguments are classified with `#[body]`, `#[binary]`, `#[query]`, `#[header]`, `#[path]`, or +//! `#[extension]`. Extension values are trusted server-local Axum context: generated clients and +//! OpenAPI omit them. An unannotated argument whose Rust name occurs in the route template is +//! inferred as a path argument. Header attributes may carry a wire name, as in +//! `#[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. //! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the //! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. //! @@ -63,9 +64,9 @@ //! deterministic `Operation` fallback. The `ApiContract::OPERATIONS` inventory is //! sorted by operation ID, so its ordering is independent of source method order. //! -//! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves -//! explicit variants for future transport work; accepting one requires a deliberate macro and -//! adapter change rather than silently treating it as JSON. +//! Empty, JSON, and bounded binary request 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. //! //! # OpenAPI 3.1 export //! @@ -115,6 +116,70 @@ pub mod openapi; #[cfg(feature = "reqwest")] pub mod reqwest; +/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies. +/// +/// 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 +/// without reserialization and lets generated Reqwest clients authorize and send the same storage. +#[derive(Clone, Default, Eq, Hash, PartialEq)] +pub struct BinaryBody(bytes::Bytes); + +impl BinaryBody { + pub const fn new(bytes: bytes::Bytes) -> Self { + Self(bytes) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn into_bytes(self) -> bytes::Bytes { + self.0 + } +} + +impl AsRef<[u8]> for BinaryBody { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl std::ops::Deref for BinaryBody { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl From for BinaryBody { + fn from(value: bytes::Bytes) -> Self { + Self(value) + } +} + +impl From> for BinaryBody { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&'static [u8]> for BinaryBody { + fn from(value: &'static [u8]) -> Self { + Self(bytes::Bytes::from_static(value)) + } +} + +impl std::fmt::Debug for BinaryBody { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BinaryBody()") + } +} + /// A transport-neutral HTTP method. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum HttpMethod { @@ -189,7 +254,7 @@ pub trait ApiContract { pub trait Operation { /// Tuple of all non-receiver parameter types in declaration order. type Parameters; - /// JSON request body type, or [`NoBody`]. + /// Typed JSON or binary request body, or [`NoBody`]. type RequestBody; /// JSON success response body type, or [`NoBody`]. type ResponseBody; diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 4f5158289..9517bed97 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -385,6 +385,24 @@ impl OpenApiOperation<'_> { Ok(()) } + pub fn binary_request_body(&mut self) -> Result<(), OpenApiError> { + if self.request_body.is_some() { + return Err(OpenApiError::InvalidContract(format!( + "multiple request bodies for `{}`", + self.operation_id + ))); + } + self.request_body = Some(json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + }, + })); + Ok(()) + } + pub fn response( &mut self, status: u16, diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs index dd54b58d6..e9a84c354 100644 --- a/crates/api-macros/src/reqwest.rs +++ b/crates/api-macros/src/reqwest.rs @@ -6,6 +6,7 @@ use std::{fmt, time::Duration}; +use bytes::Bytes; use futures::StreamExt as _; use reqwest::{ Client, Method, StatusCode, Url, @@ -319,15 +320,50 @@ impl ClientCore { } } +/// A prepared request body whose bytes are authorized and transmitted without further encoding. +pub struct EncodedBody { + bytes: Bytes, + content_type: &'static str, +} + +impl EncodedBody { + fn json(bytes: Vec) -> Self { + Self { + bytes: bytes.into(), + content_type: "application/json", + } + } + + fn binary(body: crate::BinaryBody) -> Self { + Self { + bytes: body.into_bytes(), + content_type: "application/octet-stream", + } + } +} + +impl fmt::Debug for EncodedBody { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EncodedBody") + .field("bytes", &"") + .field("content_type", &self.content_type) + .finish() + } +} + impl ClientCore { pub async fn send( &self, method: Method, url: Url, mut headers: HeaderMap, - body: Option>, + body: Option, ) -> Result { - let body_bytes = body.as_deref().unwrap_or_default(); + let body_bytes = body + .as_ref() + .map(|body| body.bytes.as_ref()) + .unwrap_or_default(); let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery]; let authorization = self .authorizer @@ -338,6 +374,12 @@ impl ClientCore { }) .map_err(|_| ClientFailure::Authorization)?; headers.extend(authorization); + if let Some(body) = body.as_ref() { + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static(body.content_type), + ); + } let mut request = self .client @@ -345,9 +387,7 @@ impl ClientCore { .headers(headers) .timeout(self.request_timeout); if let Some(body) = body { - request = request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body); + request = request.body(body.bytes); } let response = request.send().await.map_err(|error| { if error.is_timeout() { @@ -413,9 +453,16 @@ pub fn insert_header( Ok(()) } -/// Serialize the request body once. These exact bytes are authorized and transmitted. -pub fn encode_json(value: &T) -> Result, ClientFailure> { - serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding) +/// Serialize a JSON request body once. These exact bytes are authorized and transmitted. +pub fn encode_json(value: &T) -> Result { + serde_json::to_vec(value) + .map(EncodedBody::json) + .map_err(|_| ClientFailure::RequestEncoding) +} + +/// Move an explicit binary request body into the exact authorized/transmitted representation. +pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody { + EncodedBody::binary(value) } /// Response received under the configured byte limit. Its content is always redacted in Debug. diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs index a52ae4a78..2e770be44 100644 --- a/crates/api-macros/tests/expansion.rs +++ b/crates/api-macros/tests/expansion.rs @@ -1,6 +1,8 @@ #![allow(async_fn_in_trait)] -use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api}; +use api_macros::{ + ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api, +}; pub struct CreateWidget; pub struct Widget; @@ -35,6 +37,12 @@ pub trait WrapperApi { async fn search(&self) -> SearchResult; } +#[api] +pub trait BinaryApi { + #[put("/payload", operation_id = "payload.upload")] + async fn upload(&self, #[binary] payload: BinaryBody) -> Widget; +} + #[api] pub trait RawIdentifierApi { #[get("/type", operation_id = "raw.type")] @@ -97,6 +105,12 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_eq!(lookup.parameters[0].location, ParameterLocation::Path); assert_eq!(lookup.parameters[1].location, ParameterLocation::Query); + let upload = ::METADATA; + assert_eq!(upload.request_body.wire_kind, WireKind::Binary); + assert_eq!(upload.parameters[0].location, ParameterLocation::Body); + fn assert_binary_request>() {} + assert_binary_request::(); + let delete = ::METADATA; assert_eq!(delete.response.status, 204); assert_eq!(delete.response.body.wire_kind, WireKind::Empty); diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs index efc9d3a68..e75d8ab56 100644 --- a/crates/api-macros/tests/http_adapters.rs +++ b/crates/api-macros/tests/http_adapters.rs @@ -38,6 +38,12 @@ impl api_macros::HttpError for PublicError { } } +impl api_macros::HttpRequestError for PublicError { + fn from_request_rejection(status: u16, message: String) -> Self { + Self { message, status } + } +} + #[api(reqwest, axum)] pub trait WidgetApi { #[post( @@ -86,6 +92,45 @@ impl WidgetApi for WidgetService { async fn delete(&self, _widget_id: u64) {} } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BinaryReceipt { + bytes: Vec, + content_type: String, +} + +#[api(reqwest, axum)] +pub trait BinaryApi { + #[put( + "/binary", + operation_id = "binary.put", + status = 200, + error_status = 400, + additional_error_statuses = [413], + normalize_body_errors = true + )] + async fn upload( + &self, + #[header("content-type")] content_type: String, + #[binary] body: api_macros::BinaryBody, + ) -> Result; +} + +#[derive(Clone)] +pub struct BinaryService; + +impl BinaryApi for BinaryService { + async fn upload( + &self, + content_type: String, + body: api_macros::BinaryBody, + ) -> Result { + Ok(BinaryReceipt { + bytes: body.as_ref().to_vec(), + content_type, + }) + } +} + #[api(reqwest)] pub trait FailureApi { #[get("/malformed", operation_id = "failure.malformed", status = 200)] @@ -276,6 +321,71 @@ async fn generated_client_and_router_follow_the_normalized_contract() { server.abort(); } +#[tokio::test] +async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() { + let app = binary_api_axum::upload(Arc::new(BinaryService)) + .layer(api_macros::axum::framework::DefaultBodyLimit::max(4)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind binary fixture server"); + let address = listener.local_addr().expect("binary fixture address"); + let server = tokio::spawn(async move { + api_macros::axum::framework::serve(listener, app) + .await + .expect("serve binary fixture API"); + }); + + let recorded = Arc::new(Mutex::new(None)); + let client = BinaryApiClient::builder(format!("http://{address}/")) + .expect("validated base URL") + .authorizer(RecordingAuthorizer { + request: recorded.clone(), + credential: "Bearer binary-test".to_owned(), + }) + .build() + .expect("binary client build"); + + for expected in [vec![], vec![0, 1], vec![0, 255, 1, 2]] { + let receipt = client + .upload( + "text/plain".to_owned(), + api_macros::BinaryBody::from(expected.clone()), + ) + .await + .expect("binary body within the route limit"); + assert_eq!(receipt.bytes, expected); + assert_eq!(receipt.content_type, "application/octet-stream"); + assert_eq!( + recorded + .lock() + .expect("recording lock") + .as_ref() + .expect("authorized request") + .body, + expected + ); + } + + let oversized = client + .upload( + "text/plain".to_owned(), + api_macros::BinaryBody::from(vec![0, 1, 2, 3, 4]), + ) + .await + .expect_err("one byte over the route limit must be rejected"); + assert!(matches!( + oversized, + api_reqwest::ClientError::Public { status, .. } + if status == api_reqwest::framework::StatusCode::PAYLOAD_TOO_LARGE + )); + assert_eq!( + format!("{:?}", api_macros::BinaryBody::from(vec![1, 2, 3])), + "BinaryBody()" + ); + + server.abort(); +} + #[test] fn base_urls_are_strict_and_named_adapter_types_are_public() { assert!(api_reqwest::BaseUrl::try_from("ftp://example.test/").is_err()); diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index 1a79d6d30..d1c96c544 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -2,7 +2,7 @@ #![allow(async_fn_in_trait, dead_code)] use api_macros::{ - api, + BinaryBody, api, openapi::{OpenApiInfo, OpenApiSchema}, }; use schemars::JsonSchema; @@ -100,6 +100,9 @@ pub trait FixtureApi { #[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)] async fn delete(&self, #[path] widget_id: u32) -> (); + + #[put("/uploads", operation_id = "uploads.put", status = 200)] + async fn upload(&self, #[binary] body: BinaryBody) -> Widget; } fn document() -> api_macros::openapi::OpenApiDocument { @@ -282,6 +285,24 @@ fn operations_and_components_preserve_the_wire_contract() { ); } + let upload = &value["paths"]["/uploads"]["put"]; + assert_eq!(upload["operationId"], "uploads.put"); + assert_eq!( + upload["requestBody"], + json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }) + ); + assert!( + !schemas.contains_key("BinaryBody"), + "binary payloads are not JSON Schema components" + ); + let delete = &value["paths"]["/widgets/{widget_id}"]["delete"]; assert_eq!(delete["operationId"], "widgets.delete"); assert!(delete.get("security").is_none()); diff --git a/crates/api-macros/tests/ui/invalid_binary_method.rs b/crates/api-macros/tests/ui/invalid_binary_method.rs new file mode 100644 index 000000000..fecca3b7e --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_method.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidBinaryMethod { + #[get("/payload")] + async fn download(&self, #[binary] body: api_macros::BinaryBody) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_binary_method.stderr b/crates/api-macros/tests/ui/invalid_binary_method.stderr new file mode 100644 index 000000000..d972e5b71 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_method.stderr @@ -0,0 +1,5 @@ +error: GET operations cannot declare a request body + --> tests/ui/invalid_binary_method.rs:8:14 + | +8 | async fn download(&self, #[binary] body: api_macros::BinaryBody) -> Response; + | ^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_binary_type.rs b/crates/api-macros/tests/ui/invalid_binary_type.rs new file mode 100644 index 000000000..dd84af489 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_type.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidBinaryType { + #[put("/payload")] + async fn upload(&self, #[binary] body: Vec) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_binary_type.stderr b/crates/api-macros/tests/ui/invalid_binary_type.stderr new file mode 100644 index 000000000..8f6fb514d --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_binary_type.stderr @@ -0,0 +1,5 @@ +error: binary request bodies must use api_macros::BinaryBody + --> tests/ui/invalid_binary_type.rs:8:44 + | +8 | async fn upload(&self, #[binary] body: Vec) -> Response; + | ^^^^^^^ diff --git a/crates/api-macros/tests/ui/mixed_request_bodies.rs b/crates/api-macros/tests/ui/mixed_request_bodies.rs new file mode 100644 index 000000000..68eacab4f --- /dev/null +++ b/crates/api-macros/tests/ui/mixed_request_bodies.rs @@ -0,0 +1,16 @@ +use api_macros::api; + +pub struct JsonBody; +pub struct Response; + +#[api] +pub trait MixedRequestBodies { + #[post("/payload")] + async fn upload( + &self, + #[body] json: JsonBody, + #[binary] binary: api_macros::BinaryBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/mixed_request_bodies.stderr b/crates/api-macros/tests/ui/mixed_request_bodies.stderr new file mode 100644 index 000000000..f87f947f6 --- /dev/null +++ b/crates/api-macros/tests/ui/mixed_request_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one JSON or binary request body + --> tests/ui/mixed_request_bodies.rs:12:19 + | +12 | #[binary] binary: api_macros::BinaryBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_binary_bodies.rs b/crates/api-macros/tests/ui/multiple_binary_bodies.rs new file mode 100644 index 000000000..027eb2680 --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_binary_bodies.rs @@ -0,0 +1,15 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait MultipleBinaryBodies { + #[post("/payload")] + async fn upload( + &self, + #[binary] first: api_macros::BinaryBody, + #[binary] second: api_macros::BinaryBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/multiple_binary_bodies.stderr b/crates/api-macros/tests/ui/multiple_binary_bodies.stderr new file mode 100644 index 000000000..50b23843c --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_binary_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one JSON or binary request body + --> tests/ui/multiple_binary_bodies.rs:11:19 + | +11 | #[binary] second: api_macros::BinaryBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_bodies.stderr b/crates/api-macros/tests/ui/multiple_bodies.stderr index 1b259838b..76eec403f 100644 --- a/crates/api-macros/tests/ui/multiple_bodies.stderr +++ b/crates/api-macros/tests/ui/multiple_bodies.stderr @@ -1,4 +1,4 @@ -error: an API operation may declare only one request body +error: an API operation may declare only one JSON or binary request body --> tests/ui/multiple_bodies.rs:13:17 | 13 | #[body] second: SecondBody, diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 78985cf4f..76cc5884a 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -1,7 +1,5 @@ use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError}; use crate::{BackendApiClient, BackendApiClientError, Client}; -use reqwest::Method as HttpMethod; -use serde::Deserialize; pub use server_api::{ BrowserCreateWorkerResponse as BackendCreateWorkerResponse, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic, @@ -101,28 +99,17 @@ impl BackendRuntimeTarget { }, ) .await?; - let worker_path = format!( - "/api/w/{}/runtimes/{}/workers/{}", - path_segment_encode(&self.workspace_id), - path_segment_encode(&self.runtime_id), - path_segment_encode(&self.worker_id), - ); - let upload_path = format!( - "{worker_path}/attachment-uploads/{}", - path_segment_encode(&grant.upload_id), - ); - let response = api - .request(HttpMethod::PUT, &upload_path)? - .body(content) - .send() - .await - .map_err(BackendRuntimeClientError::Http)?; - api.check_status(response.status())?; - response - .json::() + client + .runtime_worker_attachment_upload( + self.workspace_id.clone(), + self.runtime_id.clone(), + self.worker_id.clone(), + grant.upload_id, + server_api::BinaryBody::from(content), + ) .await .map(|response| response.file) - .map_err(BackendRuntimeClientError::Http) + .map_err(BackendRuntimeClientError::from) } pub async fn cancel_file_upload( @@ -158,11 +145,6 @@ impl BackendRuntimeTarget { } } -#[derive(Deserialize)] -struct UploadedFileResponse { - file: protocol::UploadedFileRef, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendWorkerLaunchTarget { pub base_url: String, diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 239025fc3..6fff383f6 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use api_macros::api; pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; -pub use api_macros::{ApiContract, HttpMethod}; +pub use api_macros::{ApiContract, BinaryBody, HttpMethod}; pub mod repository_openapi_typescript; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -320,6 +320,7 @@ impl_openapi_schema!( RuntimeCleanupExecutionResponse, WorkerRetentionResponse, WorkerFileUploadQuery, + WorkerFileUploadResponse, AttachmentUploadGrantResponse, AttachmentUploadCancelResponse, WorkerFileDeleteResponse, @@ -1860,6 +1861,25 @@ pub trait ServerApi { #[query] query: WorkerFileUploadQuery, ) -> Result; + #[put( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 413, 500, 502, 503], + bearer_auth = true, + browser_auth = true, + normalize_body_errors = true + )] + async fn runtime_worker_attachment_upload( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[path] upload_id: String, + #[binary] body: BinaryBody, + ) -> Result; + #[delete( "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", status = 200, @@ -6539,7 +6559,7 @@ pub struct WorkerFileUploadQuery { pub upload_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkerFileUploadResponse { pub file: protocol::UploadedFileRef, @@ -10527,6 +10547,39 @@ mod openapi_artifact_tests { ); } + #[test] + fn attachment_upload_openapi_uses_required_binary_body_and_json_response() { + let value = canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .as_value() + .clone(); + let operation = &value["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}"] + ["put"]; + assert_eq!(operation["operationId"], "runtime_worker_attachment_upload"); + assert_eq!( + operation["requestBody"], + serde_json::json!({ + "required": true, + "content": { + "application/octet-stream": { + "schema": { "type": "string", "format": "binary" } + } + } + }) + ); + assert_eq!( + operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/WorkerFileUploadResponse" + ); + assert!( + value["components"]["schemas"] + .as_object() + .expect("schema components") + .get("BinaryBody") + .is_none() + ); + } + #[test] fn server_api_openapi_security_matches_workspace_server_auth_semantics() { const PUBLIC_UNAUTHENTICATED: &[&str] = &[ diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 33a57282f..bccad1da2 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -924,6 +924,12 @@ fn validate_runtime_operations( None, &[("200", "AttachmentUploadGrantResponse")], ), + binary_runtime_operation( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", + "put", + "runtime_worker_attachment_upload", + &[("200", "WorkerFileUploadResponse")], + ), runtime_operation( "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", "delete", @@ -996,6 +1002,24 @@ const fn runtime_operation( method, operation_id, request, + binary_request: false, + successes, + error: "RepositoryApiError", + } +} + +const fn binary_runtime_operation( + path: &'static str, + method: &'static str, + operation_id: &'static str, + successes: &'static [(&'static str, &'static str)], +) -> RuntimeOperationContract { + RuntimeOperationContract { + path, + method, + operation_id, + request: None, + binary_request: true, successes, error: "RepositoryApiError", } @@ -1015,23 +1039,28 @@ fn validate_runtime_operation_contracts( operation.method, operation.path, operation.operation_id ))); } - match operation.request { - Some(expected) => { - let request = object_field(value, "requestBody", operation.operation_id)?; - require_schema_ref( - json_content_schema(request, operation.operation_id)?, - expected, - operation.operation_id, - )?; - roots.insert(expected.to_owned()); + if operation.binary_request { + let request = object_field(value, "requestBody", operation.operation_id)?; + validate_binary_request_body(request, operation.operation_id)?; + } else { + match operation.request { + Some(expected) => { + let request = object_field(value, "requestBody", operation.operation_id)?; + require_schema_ref( + json_content_schema(request, operation.operation_id)?, + expected, + operation.operation_id, + )?; + roots.insert(expected.to_owned()); + } + None if value.contains_key("requestBody") => { + return Err(GenerationError::invalid(format!( + "{} unexpectedly gained a request body", + operation.operation_id + ))); + } + None => {} } - None if value.contains_key("requestBody") => { - return Err(GenerationError::invalid(format!( - "{} unexpectedly gained a request body", - operation.operation_id - ))); - } - None => {} } let responses = object_field(value, "responses", operation.operation_id)?; let successes = operation @@ -1072,6 +1101,7 @@ struct RuntimeOperationContract { method: &'static str, operation_id: &'static str, request: Option<&'static str>, + binary_request: bool, successes: &'static [(&'static str, &'static str)], error: &'static str, } @@ -1146,6 +1176,36 @@ struct OperationContract { successes: &'static [(&'static str, &'static str)], } +fn validate_binary_request_body( + request: &Map, + context: &str, +) -> Result<(), GenerationError> { + if request.get("required").and_then(Value::as_bool) != Some(true) { + return Err(GenerationError::invalid(format!( + "{context} binary request body must be required" + ))); + } + let content = object_field(request, "content", context)?; + if content.len() != 1 { + return Err(GenerationError::invalid(format!( + "{context} binary request body must expose only application/octet-stream" + ))); + } + let media = object_field(content, "application/octet-stream", context)?; + let schema = media.get("schema").ok_or_else(|| { + GenerationError::invalid(format!("{context} is missing its binary schema")) + })?; + let schema = object(schema, context)?; + if schema.get("type").and_then(Value::as_str) != Some("string") + || schema.get("format").and_then(Value::as_str) != Some("binary") + { + return Err(GenerationError::invalid(format!( + "{context} binary request schema must be type string with format binary" + ))); + } + Ok(()) +} + fn json_content_schema<'a>( value: &'a Map, context: &str, @@ -1828,6 +1888,8 @@ mod tests { ))); assert!(output.contains("export type RuntimeCleanupPlanResponse =")); assert!(output.contains("export type RuntimeWorkerLifecycleResult =")); + assert!(output.contains("export type WorkerFileUploadResponse =")); + assert!(output.contains("export type UploadedFileRef =")); assert!(output.contains( "export type RuntimeWorkerSpawnAcceptanceRequirement = { kind: \"socket_ready\"; } | { expected_segments: number; kind: \"run_accepted\"; };" )); @@ -1839,6 +1901,13 @@ mod tests { let error = generate_runtime_typescript(&document.to_string()).unwrap_err(); assert!(error.to_string().contains("runtime_cleanup_plan")); + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}"] + ["put"]["requestBody"]["content"]["application/octet-stream"]["schema"]["format"] = + serde_json::json!("byte"); + let error = generate_runtime_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("format binary")); + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); document["components"]["schemas"]["RuntimeWorkerSpawnAcceptanceRequirement"] ["oneOf"] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fc9839c18..7eff19849 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5,7 +5,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use async_trait::async_trait; -use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State}; use axum::http::header::{ @@ -14,7 +13,7 @@ use axum::http::header::{ use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post, put}; +use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::{Duration, SecondsFormat, Utc}; use config_source::ConfigTreeSnapshot; @@ -4223,6 +4222,10 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou service.clone(), )) .merge(server_api::server_api_axum::runtime_worker_attachment_upload_grant(service.clone())) + .merge( + server_api::server_api_axum::runtime_worker_attachment_upload(service.clone()) + .layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)), + ) .merge( server_api::server_api_axum::runtime_worker_attachment_upload_cancel(service.clone()), ) @@ -6536,6 +6539,30 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn runtime_worker_attachment_upload( + &self, + actor: RequestActor, + workspace_id: String, + runtime_id: String, + worker_id: String, + upload_id: String, + body: server_api::BinaryBody, + ) -> std::result::Result { + upload_runtime_worker_file_contract( + self.workspace_api()?.clone(), + actor, + ScopedAttachmentUploadPath { + workspace_id, + runtime_id, + worker_id, + upload_id, + }, + body.as_ref(), + ) + .await + .map_err(ApiError::into_repository_api_error) + } + async fn runtime_worker_attachment_upload_cancel( &self, actor: RequestActor, @@ -8242,11 +8269,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/protocol/ws", get(scoped_workspace_protocol_ws), ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}", - put(scoped_upload_runtime_worker_file) - .layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)), - ) .route( "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", get(worker_protocol_ws), @@ -18658,14 +18680,14 @@ async fn scoped_create_attachment_upload_grant( })) } -async fn scoped_upload_runtime_worker_file( - State(api): State, - Extension(actor): Extension, - AxumPath(path): AxumPath, - body: Bytes, -) -> ApiResult> { +async fn upload_runtime_worker_file_contract( + api: WorkspaceApi, + actor: RequestActor, + path: ScopedAttachmentUploadPath, + body: &[u8], +) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; - let body_sha256 = attachment_body_sha256(&body); + let body_sha256 = attachment_body_sha256(body); let grant = { let mut grants = api .attachment_upload_grants @@ -18685,7 +18707,7 @@ async fn scoped_upload_runtime_worker_file( ) .map_err(ApiError::from)? { - return Ok(Json(WorkerFileUploadResponse { file })); + return Ok(WorkerFileUploadResponse { file }); } grant.clone() }; @@ -18702,7 +18724,7 @@ async fn scoped_upload_runtime_worker_file( &worker, &grant.file_name, &grant.media_type, - &body, + body, Some(&upload_context), ); let mut grants = api @@ -18726,7 +18748,7 @@ async fn scoped_upload_runtime_worker_file( match uploaded { Ok(file) => { current.state = AttachmentUploadGrantState::Completed(file.clone()); - Ok(Json(WorkerFileUploadResponse { file })) + Ok(WorkerFileUploadResponse { file }) } Err(error) => { current.state = AttachmentUploadGrantState::Pending; @@ -26549,6 +26571,113 @@ mod tests { )); } + #[tokio::test] + async fn generated_attachment_upload_route_preserves_the_host_specific_body_limit() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let successful_body = vec![0, 255, 1, 2]; + let successful_sha256 = attachment_body_sha256(&successful_body); + let successful_file = completed_upload_file(&successful_sha256); + api.attachment_upload_grants.lock().unwrap().insert( + "completed".to_string(), + AttachmentUploadGrant { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-1".to_string(), + worker_id: "worker-1".to_string(), + account_id: format!("account-{TEST_WORKSPACE_ID}"), + file_name: successful_file.file_name.clone(), + media_type: successful_file.media_type.clone(), + expires_at_ms: attachment_upload_now_ms().unwrap() + 60_000, + state: AttachmentUploadGrantState::Completed(successful_file.clone()), + }, + ); + let router = generated_workspace_contract_router(ServerApiContractService::Workspace(api)) + .layer(Extension(test_owner_actor())); + let uri = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/runtime-1/workers/worker-1/attachment-uploads/unknown" + ); + + for body in [ + Vec::new(), + vec![0, 1, 2], + vec![0; MAX_WORKER_FILE_UPLOAD_BYTES], + ] { + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(&uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "bodies through the exact route limit must reach upload grant validation" + ); + } + + let completed_uri = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/runtime-1/workers/worker-1/attachment-uploads/completed" + ); + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(&completed_uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(successful_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let uploaded: WorkerFileUploadResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(uploaded.file, successful_file); + + let response = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(completed_uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![9, 9, 9])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = router + .oneshot( + Request::builder() + .method("PUT") + .uri(uri) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![0; MAX_WORKER_FILE_UPLOAD_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/json" + ); + let error: server_api::RepositoryApiError = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(error.error, "Payload Too Large"); + } + #[tokio::test] async fn command_session_survives_attachment_refresh_until_worker_revocation() { let directory = tempfile::tempdir().unwrap(); diff --git a/openapi/server-api.json b/openapi/server-api.json index 3dc4f0db8..a30ce4d0c 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -11136,6 +11136,18 @@ ], "type": "object" }, + "WorkerFileUploadResponse": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/components/schemas/UploadedFileRef" + } + }, + "required": [ + "file" + ], + "type": "object" + }, "WorkerImplementationSummary": { "properties": { "display_hint": { @@ -13381,7 +13393,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe" + "x-yoi-source-digest": "sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d" }, "openapi": "3.1.0", "paths": { @@ -22633,6 +22645,172 @@ "browserSession": [] } ] + }, + "put": { + "operationId": "runtime_worker_attachment_upload", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "runtime_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "worker_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "upload_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkerFileUploadResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index d51e24ef3..20360376f 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 591b9e117..79244eeba 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 993c42327..63e9f27d1 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check @@ -127,12 +127,18 @@ export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticke export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; }; +export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed"; + +export type UploadedFileRef = { artifact_id: string; availability: UploadedFileAvailability; byte_len: number; created_at_ms: number; file_name: string; media_type: string; sha256: string; source_entry_id?: string | null; }; + export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; export type WorkerCapabilitySummary = { can_spawn_followup: boolean; can_stop: boolean; }; export type WorkerFileDeleteResponse = { deleted: boolean; }; +export type WorkerFileUploadResponse = { file: UploadedFileRef; }; + export type WorkerImplementationSummary = { display_hint: string; kind: string; }; export type WorkerMaintenanceState = "compacting"; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 19d8e011c..380ffe066 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 7d381c0e5..8b375ad40 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 79f151a12..33a39ea29 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:6b26856793bfd6dd3d34461b3a7422adfdf0fab74c0ebab5ef6ab7fbdf034dfe) +// Input contract: 0.1.0 (sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/console/composer-attachments.ts b/web/workspace/src/lib/workspace/console/composer-attachments.ts index 15737998c..4d4e557b5 100644 --- a/web/workspace/src/lib/workspace/console/composer-attachments.ts +++ b/web/workspace/src/lib/workspace/console/composer-attachments.ts @@ -1,4 +1,7 @@ -import type { AttachmentUploadGrantResponse } from "$lib/generated/runtime-api.ts"; +import type { + AttachmentUploadGrantResponse, + WorkerFileUploadResponse, +} from "$lib/generated/runtime-api.ts"; import type { UploadedFileRef } from "$lib/generated/protocol.ts"; export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024; @@ -151,7 +154,7 @@ function isUploadGrantResponse( function isUploadedFileResponse( value: unknown, -): value is { file: UploadedFileRef } { +): value is WorkerFileUploadResponse { if (!value || typeof value !== "object" || !("file" in value)) return false; const file = value.file; return !!file && typeof file === "object" &&