Type the blob-write response hash as fabro_types::BlobHash

Promote BlobHash to a named OpenAPI schema with the ^[0-9a-f]{64}$
pattern, reference it from WriteBlobResponse.hash and the blobHash path
parameter, and map it to fabro_types::BlobHash via with_replacement.
The server now serializes the domain type directly and the client gets
a parsed BlobHash by construction, removing the to_string/parse adapter
pair across the wire boundary. Adds the JSON-parity test required for
new replacements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-08-16 10:35:18 -04:00
parent f1c00a167e
commit 88b2a01af8
7 changed files with 64 additions and 17 deletions

View file

@ -5980,8 +5980,7 @@ components:
required: true
description: Content-addressed blob hash.
schema:
type: string
pattern: '^[0-9a-f]{64}$'
$ref: "#/components/schemas/BlobHash"
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
ArtifactFilename:
@ -10283,6 +10282,12 @@ components:
description: Assigned event sequence number.
example: 42
BlobHash:
description: Content-addressed SHA-256 hash of a stored blob.
type: string
pattern: "^[0-9a-f]{64}$"
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
WriteBlobResponse:
description: Content-addressed hash of a stored blob.
type: object
@ -10290,9 +10295,7 @@ components:
- hash
properties:
hash:
type: string
description: Content-addressed hash of the stored blob.
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
$ref: "#/components/schemas/BlobHash"
CommandTermination:
description: Terminal state for a command execution.

View file

@ -105,10 +105,7 @@ async fn write_run_blob(
}
match state.stores.runs.open_run(&id).await {
Ok(run_store) => match run_store.write_blob(&body).await {
Ok(blob_hash) => Json(WriteBlobResponse {
hash: blob_hash.to_string(),
})
.into_response(),
Ok(blob_hash) => Json(WriteBlobResponse { hash: blob_hash }).into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}

View file

@ -725,6 +725,7 @@ fn main() {
("WorkflowVersion", "fabro_types::WorkflowVersion", &[]),
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
("BlobHash", "fabro_types::BlobHash", &[]),
("CostSource", "fabro_model::CostSource", &[]),
];
for (name, path, impls) in replacements {

View file

@ -42,7 +42,7 @@ pub mod types {
pub use fabro_types::{
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts,
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,

View file

@ -0,0 +1,50 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{BlobHash as ApiBlobHash, WriteBlobResponse};
use fabro_types::BlobHash;
use serde_json::json;
const BLOB_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
#[test]
fn blob_hash_schema_reuses_domain_type() {
assert_same_type::<ApiBlobHash, BlobHash>();
}
#[test]
fn write_blob_response_round_trips_exact_wire_shape() {
let value = json!({ "hash": BLOB_HASH });
let response: WriteBlobResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(&response).unwrap(), value);
}
#[test]
fn blob_hash_emits_the_documented_lowercase_pattern() {
// Serialization must match the OpenAPI schema pattern `^[0-9a-f]{64}$`.
let hash: ApiBlobHash = serde_json::from_value(json!(BLOB_HASH)).unwrap();
let emitted = serde_json::to_value(hash).unwrap();
assert_eq!(emitted, json!(BLOB_HASH));
let text = emitted.as_str().unwrap();
assert_eq!(text.len(), 64);
assert!(
text.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
);
}
#[test]
fn blob_hash_rejects_non_hex_values() {
assert!(serde_json::from_value::<ApiBlobHash>(json!("not-a-blob-hash")).is_err());
}
fn assert_same_type<Api: 'static, Domain: 'static>() {
assert_eq!(
TypeId::of::<Api>(),
TypeId::of::<Domain>(),
"{} must be the domain type {}",
type_name::<Api>(),
type_name::<Domain>()
);
}

View file

@ -1839,11 +1839,7 @@ impl Client {
.await
})
.await?;
response
.into_inner()
.hash
.parse()
.context("write_run_blob returned invalid blob hash")
Ok(response.into_inner().hash)
}
pub async fn read_run_blob(
@ -1856,7 +1852,7 @@ impl Client {
.client
.read_run_blob()
.id(run_id.to_string())
.blob_hash(blob_hash.to_string())
.blob_hash(*blob_hash)
.send()
.await;
match response {

View file

@ -19,7 +19,7 @@
*/
export interface WriteBlobResponse {
/**
* Content-addressed hash of the stored blob.
* Content-addressed SHA-256 hash of a stored blob.
*/
'hash': string;
}