feat: add bounded binary API request bodies

This commit is contained in:
2026-09-23 18:29:54 +09:00
parent 2766e5b41b
commit 97822cf691
32 changed files with 1050 additions and 149 deletions
+55 -2
View File
@@ -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<AttachmentUploadGrantResponse, RepositoryApiError>;
#[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<WorkerFileUploadResponse, RepositoryApiError>;
#[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<String>,
}
#[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] = &[
@@ -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<String, Value>,
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<String, Value>,
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"]