feat: add bounded binary API request bodies
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user